Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,42 @@ Peer dependencies: `react ^19`, `react-dom ^19`, `next ^16`, `framer-motion ^12`

Each component is exported on its own subpath — `@isimplifyme/ui/bento`, `@isimplifyme/ui/seo`, `@isimplifyme/ui/concierge`, and so on. See `package.json` `exports` for the full map.

## Click beacon

`OutboundClicks` counts the clicks a server access log cannot see: phone taps, email links, links off the site, and anything the page labels itself. A click sends one `GET /out.txt?e=<event>&p=<page>&…&t=<ms>` for a tiny static file, and the CDN's own access log is the counter — no cookie, no identifier, no third party, no server code, and the visitor's link is never rewritten.

```tsx
// app/layout.tsx
import { OutboundClicks } from '@isimplifyme/ui/outbound-clicks'
;<OutboundClicks />

// anywhere
<a href="tel:+13125002674" data-track="header">(312) 500-2674</a>
<button data-track="book-online">Book online</button>
```

The consuming site must ship the file the beacon asks for — `public/out.txt`, any short body — or every click is a 404 (still logged, but it reads as an error).

| `e` | fires on | also carries |
| --- | --- | --- |
| `tel` | `a[href^="tel:"]` | `n` label |
| `mailto` | `a[href^="mailto:"]` | `n` label |
| `out` | an `http(s)` link to another host | `h` hostname, `n` label |
| `amazon` / `merchant` | `a[data-amazon]` / `a[data-merchant]` | `n` product, `i` position, `m` merchant |
| `track` | any `[data-track]` that is not inside a link | `n` label |

What may leave the page is an event name, the current path, a hostname, and labels written in the site's own source — never a phone number, an email address, a link's path or query, or anything a visitor typed. `BEACON_KEYS` in `src/outbound.ts` is the whole vocabulary, and `src/outbound.test.ts` fails if a new key appears without being named there.

Internal links are deliberately silent: a click that ends in a request to our own server is already in the same log as a page view. Pass `ignoreHosts` for hostnames the site considers its own, and `enabled={false}` (or mount inside a consent gate) where a site asks before it measures.

## Tests

```bash
npm install
npm test # vitest
npm run typecheck
```

## Security

Report security issues to [ai@isimplifyme.com](mailto:ai@isimplifyme.com).
Expand Down
27 changes: 20 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@isimplifyme/ui",
"version": "1.2.1",
"description": "React/Next.js UI library for iSimplifyMe properties — design system, article layouts, SEO helpers, bot middleware, and the iSM Concierge widget.",
"version": "1.3.0",
"description": "React/Next.js UI library for iSimplifyMe properties \u2014 design system, article layouts, SEO helpers, bot middleware, and the iSM Concierge widget.",
"homepage": "https://isimplifyme.com",
"type": "module",
"exports": {
Expand All @@ -23,10 +23,18 @@
"./image-lightbox": "./src/image-lightbox.tsx",
"./related-articles": "./src/related-articles.tsx",
"./bot-middleware": "./src/bot-middleware.ts",
"./concierge": "./src/concierge.tsx"
"./concierge": "./src/concierge.tsx",
"./outbound": "./src/outbound.ts",
"./outbound-clicks": "./src/outbound-clicks.tsx"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"files": [
"src"
"src",
"!src/**/*.test.ts",
"!src/**/*.test.tsx"
],
"peerDependencies": {
"react": "^19",
Expand All @@ -35,12 +43,17 @@
"next": "^16.2.5"
},
"devDependencies": {
"react": "^19",
"react-dom": "^19",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
"framer-motion": "^12",
"jsdom": "^27",
"next": "^16",
"react": "^19",
"react-dom": "^19",
"typescript": "^6",
"@types/react": "^19"
"vitest": "^4",
"web-vitals": "^5"
},
"repository": {
"type": "git",
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ export { Analytics, WebVitals, trackEvent, analytics } from './analytics'
export { ArticleNav } from './article-nav'
export { SearchFilter } from './search-filter'
export { StagingBanner } from './staging-banner'
export { OutboundClicks } from './outbound-clicks'
export type { OutboundClicksProps } from './outbound-clicks'
export { OUTBOUND_BEACON_PATH, BEACON_KEYS, beaconUrl, describeClick } from './outbound'
export type { OutboundClick, OutboundKind, ClickContext } from './outbound'
73 changes: 73 additions & 0 deletions src/outbound-clicks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client'

import { useEffect } from 'react'
import { OUTBOUND_BEACON_PATH, beaconUrl, describeClick } from './outbound'

export interface OutboundClicksProps {
/** Path of the static file to request. Must exist — ship `public/out.txt`. */
beaconPath?: string
/**
* Hostnames to treat as internal alongside the page's own: a staging domain,
* a sibling property, a booking host the site considers part of itself.
*/
ignoreHosts?: readonly string[]
/**
* Set false to send nothing. A site that asks before analytics mounts this
* inside its consent gate, or passes the gate's answer here.
*/
enabled?: boolean
}

/**
* Counts the clicks a server log cannot see — phone taps, email links, links
* off the site, and anything the page labels with `data-track` (see
* ./outbound.ts for what may leave the page).
*
* One delegated listener on the document, so every page stays a server
* component and nothing has to be wrapped. Renders null.
*
* <OutboundClicks /> // in the root layout
* <a href="tel:+13125002674" data-track="header">…</a>
* <button data-track="book-online">…</button>
*
* Put `data-track` on the thing that gets clicked, not on a section around it:
* the label is read from the nearest labelled ancestor, so a wrapper would
* claim every click inside it.
*/
export function OutboundClicks({ beaconPath = OUTBOUND_BEACON_PATH, ignoreHosts, enabled = true }: OutboundClicksProps) {
// A caller's inline array is a new array every render; its contents are not.
const ignoreKey = (ignoreHosts ?? []).join(',')
useEffect(() => {
if (!enabled) return
const ignored = ignoreKey ? ignoreKey.split(',') : []
const onClick = (event: MouseEvent) => {
// Left click, or middle click (auxclick) opening a background tab.
if (event.type === 'auxclick' && event.button !== 1) return
const target = event.target as Element | null
if (!target?.closest) return
const click = describeClick(target, {
pathname: window.location.pathname,
hostname: window.location.hostname,
ignoreHosts: ignored,
})
if (!click) return
// keepalive lets the request finish if the tab navigates; credentials
// are omitted so not even our own cookies ride along.
fetch(beaconUrl(click, Date.now(), beaconPath), {
method: 'GET',
keepalive: true,
cache: 'no-store',
credentials: 'omit',
}).catch(() => {})
}
document.addEventListener('click', onClick, true)
document.addEventListener('auxclick', onClick, true)
return () => {
document.removeEventListener('click', onClick, true)
document.removeEventListener('auxclick', onClick, true)
}
}, [beaconPath, ignoreKey, enabled])
return null
}

export default OutboundClicks
Loading
Loading