@m13v/seo-components 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/seo-components",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "scripts": {
5
5
  "build:css": "tailwind -i src/_build.css -o dist/styles.css --minify",
6
6
  "lint:mobile-spans": "node scripts/lint-mobile-spans.mjs",
package/registry.json CHANGED
@@ -290,6 +290,12 @@
290
290
  "quotaTags": ["visual-rich-mandatory"]
291
291
  },
292
292
 
293
+ {
294
+ "name": "NewsStrip",
295
+ "group": "cta",
296
+ "signature": "(href, lead, wedge, pillText?, ctaLabel?, tone?: \"amber\" | \"teal\" | \"rose\" | \"indigo\", site?, section?, datePublished?)",
297
+ "description": "Pinned, pill-anchored announcement strip that sits ABOVE the hero on the homepage and routes existing organic clickthroughs to a freshly minted /t/ analysis. Pattern: a viral post on Twitter/Reddit drives baseline traffic to '/'. Once a dedicated /t/ page ships, every subsequent visitor needs a one-click path from '/' to the deep page. Render at top of homepage, target the new /t/ slug. Tone defaults to amber for breaking news. The seo_top_posts pipeline mounts this automatically when it ships a page from a viral post."
298
+ },
293
299
  {
294
300
  "name": "InlineCta",
295
301
  "group": "cta",
@@ -0,0 +1,260 @@
1
+ "use client";
2
+
3
+ import { useCapture } from "../lib/analytics-context";
4
+
5
+ export interface NewsStripProps {
6
+ /** Required: link target. Internal /t/ slugs go to the freshly minted page. */
7
+ href: string;
8
+ /** Required: short eyebrow text displayed in the pill (e.g. "MAY 6"). Defaults to "NEW". */
9
+ pillText?: string;
10
+ /** Required: lead phrase rendered in bold inside the body (e.g. "Anthropic doubled limits."). */
11
+ lead: string;
12
+ /** Required: the wedge / hook explaining why the user should click through. */
13
+ wedge: string;
14
+ /** CTA label pinned right. Defaults to "Read the breakdown". */
15
+ ctaLabel?: string;
16
+ /** Color theme. Defaults to amber for breaking news. */
17
+ tone?: "amber" | "teal" | "rose" | "indigo";
18
+ /** Site slug for analytics events. */
19
+ site?: string;
20
+ /** Section label for analytics events. */
21
+ section?: string;
22
+ /** When true, the pill animates with a pulsing dot. Defaults to true. */
23
+ showDot?: boolean;
24
+ /** Optional ISO date the news lands; surfaced as <time> for SEO/aria. */
25
+ datePublished?: string;
26
+ }
27
+
28
+ const TONES: Record<NonNullable<NewsStripProps["tone"]>, {
29
+ bg: string;
30
+ border: string;
31
+ borderHover: string;
32
+ text: string;
33
+ textStrong: string;
34
+ textWedge: string;
35
+ pillBg: string;
36
+ pillText: string;
37
+ arrow: string;
38
+ shadowRgb: string;
39
+ }> = {
40
+ amber: {
41
+ bg: "linear-gradient(135deg, #fff7ed 0%, #fffbeb 100%)",
42
+ border: "#fde68a",
43
+ borderHover: "#f59e0b",
44
+ text: "#92400e",
45
+ textStrong: "#78350f",
46
+ textWedge: "#b45309",
47
+ pillBg: "#d97706",
48
+ pillText: "#ffffff",
49
+ arrow: "#b45309",
50
+ shadowRgb: "180, 83, 9",
51
+ },
52
+ teal: {
53
+ bg: "linear-gradient(135deg, #ecfeff 0%, #f0fdfa 100%)",
54
+ border: "#99f6e4",
55
+ borderHover: "#14b8a6",
56
+ text: "#115e59",
57
+ textStrong: "#134e4a",
58
+ textWedge: "#0f766e",
59
+ pillBg: "#0d9488",
60
+ pillText: "#ffffff",
61
+ arrow: "#0f766e",
62
+ shadowRgb: "13, 148, 136",
63
+ },
64
+ rose: {
65
+ bg: "linear-gradient(135deg, #fff1f2 0%, #fef2f2 100%)",
66
+ border: "#fecdd3",
67
+ borderHover: "#f43f5e",
68
+ text: "#9f1239",
69
+ textStrong: "#881337",
70
+ textWedge: "#be123c",
71
+ pillBg: "#e11d48",
72
+ pillText: "#ffffff",
73
+ arrow: "#be123c",
74
+ shadowRgb: "225, 29, 72",
75
+ },
76
+ indigo: {
77
+ bg: "linear-gradient(135deg, #eef2ff 0%, #eff6ff 100%)",
78
+ border: "#c7d2fe",
79
+ borderHover: "#6366f1",
80
+ text: "#3730a3",
81
+ textStrong: "#312e81",
82
+ textWedge: "#4338ca",
83
+ pillBg: "#4f46e5",
84
+ pillText: "#ffffff",
85
+ arrow: "#4338ca",
86
+ shadowRgb: "79, 70, 229",
87
+ },
88
+ };
89
+
90
+ /**
91
+ * <NewsStrip /> — a pinned, pill-anchored, dismiss-free news bar that sits
92
+ * above the hero on a marketing homepage and routes existing organic
93
+ * clickthroughs (e.g. tweets pointing at "/") to a freshly minted dedicated
94
+ * page on a current event.
95
+ *
96
+ * Pattern this component encodes (battle-tested on claude-meter.com on
97
+ * 2026-05-07): a viral post on Twitter/Reddit drives baseline traffic to the
98
+ * homepage. Once a dedicated /t/ analysis ships, every subsequent visitor
99
+ * needs a one-click path from "/" to the deep page. NewsStrip is that path.
100
+ *
101
+ * Renders a single horizontal flex link with three slots:
102
+ * [pill] [lead + wedge] [arrow + cta]
103
+ * Tone is configurable per launch (amber for breaking news, teal/indigo/rose
104
+ * for softer announcements). Wraps gracefully on mobile.
105
+ *
106
+ * Analytics: fires `news_strip_click` via the consumer's PostHog instance
107
+ * (no-op if the page doesn't mount <SeoAnalyticsProvider>).
108
+ */
109
+ export function NewsStrip({
110
+ href,
111
+ pillText = "NEW",
112
+ lead,
113
+ wedge,
114
+ ctaLabel = "Read the breakdown",
115
+ tone = "amber",
116
+ site,
117
+ section = "homepage",
118
+ showDot = true,
119
+ datePublished,
120
+ }: NewsStripProps) {
121
+ const t = TONES[tone];
122
+ const capture = useCapture();
123
+
124
+ const onClick = () => {
125
+ try {
126
+ capture?.("news_strip_click", {
127
+ destination: href,
128
+ site,
129
+ section,
130
+ pill_text: pillText,
131
+ lead,
132
+ tone,
133
+ component: "NewsStrip",
134
+ });
135
+ } catch {
136
+ /* analytics failures must never block navigation */
137
+ }
138
+ };
139
+
140
+ return (
141
+ <a
142
+ href={href}
143
+ onClick={onClick}
144
+ aria-label={`${pillText}: ${lead} ${wedge} ${ctaLabel}`}
145
+ style={{
146
+ position: "relative",
147
+ zIndex: 3,
148
+ display: "flex",
149
+ alignItems: "center",
150
+ gap: 14,
151
+ maxWidth: 1240,
152
+ margin: "16px auto 0",
153
+ padding: "12px 22px",
154
+ borderRadius: 999,
155
+ background: t.bg,
156
+ border: `1px solid ${t.border}`,
157
+ boxShadow: `0 1px 0 rgba(${t.shadowRgb}, 0.04), 0 6px 24px -12px rgba(${t.shadowRgb}, 0.18)`,
158
+ textDecoration: "none",
159
+ color: t.text,
160
+ fontSize: 14,
161
+ lineHeight: 1.4,
162
+ transition: "transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease",
163
+ }}
164
+ className="news-strip"
165
+ data-tone={tone}
166
+ onMouseOver={(e) => {
167
+ const el = e.currentTarget as HTMLAnchorElement;
168
+ el.style.transform = "translateY(-1px)";
169
+ el.style.borderColor = t.borderHover;
170
+ el.style.boxShadow = `0 1px 0 rgba(${t.shadowRgb}, 0.08), 0 12px 32px -10px rgba(${t.shadowRgb}, 0.28)`;
171
+ }}
172
+ onMouseOut={(e) => {
173
+ const el = e.currentTarget as HTMLAnchorElement;
174
+ el.style.transform = "";
175
+ el.style.borderColor = t.border;
176
+ el.style.boxShadow = `0 1px 0 rgba(${t.shadowRgb}, 0.04), 0 6px 24px -12px rgba(${t.shadowRgb}, 0.18)`;
177
+ }}
178
+ >
179
+ <span
180
+ aria-hidden
181
+ style={{
182
+ flex: "none",
183
+ display: "inline-flex",
184
+ alignItems: "center",
185
+ gap: 6,
186
+ padding: "4px 10px",
187
+ borderRadius: 999,
188
+ background: t.pillBg,
189
+ color: t.pillText,
190
+ fontFamily:
191
+ "var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace",
192
+ fontSize: 11,
193
+ fontWeight: 700,
194
+ letterSpacing: "0.08em",
195
+ whiteSpace: "nowrap",
196
+ }}
197
+ >
198
+ {showDot && (
199
+ <span
200
+ aria-hidden
201
+ style={{
202
+ width: 6,
203
+ height: 6,
204
+ borderRadius: "50%",
205
+ background: "#ffffff",
206
+ animation: "newsStripPulse 2s infinite",
207
+ }}
208
+ />
209
+ )}
210
+ {pillText}
211
+ </span>
212
+
213
+ <span style={{ flex: "1 1 auto", minWidth: 0 }}>
214
+ <strong style={{ color: t.textStrong, fontWeight: 700 }}>{lead}</strong>{" "}
215
+ <span style={{ color: t.textWedge }}>{wedge}</span>
216
+ {datePublished && (
217
+ <time
218
+ dateTime={datePublished}
219
+ style={{ display: "none" }}
220
+ aria-hidden
221
+ >
222
+ {datePublished}
223
+ </time>
224
+ )}
225
+ </span>
226
+
227
+ <span
228
+ aria-hidden
229
+ style={{
230
+ flex: "none",
231
+ fontFamily:
232
+ "var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace",
233
+ fontSize: 12,
234
+ fontWeight: 600,
235
+ color: t.arrow,
236
+ letterSpacing: "0.02em",
237
+ whiteSpace: "nowrap",
238
+ }}
239
+ >
240
+ {ctaLabel} &rarr;
241
+ </span>
242
+
243
+ <style>{`
244
+ @keyframes newsStripPulse {
245
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.6); }
246
+ 50% { box-shadow: 0 0 0 4px rgba(255, 255, 255, 0); }
247
+ }
248
+ @media (max-width: 720px) {
249
+ a.news-strip {
250
+ margin: 12px 18px 0 !important;
251
+ padding: 10px 14px !important;
252
+ border-radius: 14px !important;
253
+ flex-wrap: wrap;
254
+ font-size: 13px !important;
255
+ }
256
+ }
257
+ `}</style>
258
+ </a>
259
+ );
260
+ }
package/src/index.ts CHANGED
@@ -37,6 +37,10 @@ export { AnimatedSection } from "./components/AnimatedSection";
37
37
  export { AnimatedMetric } from "./components/AnimatedMetric";
38
38
  export { MetricsRow } from "./components/MetricsRow";
39
39
 
40
+ // News bar (announcement strip linking to a freshly minted /t/ analysis)
41
+ export { NewsStrip } from "./components/NewsStrip";
42
+ export type { NewsStripProps } from "./components/NewsStrip";
43
+
40
44
  // CTA components
41
45
  export { InlineCta } from "./components/InlineCta";
42
46
  export { StickyBottomCta } from "./components/StickyBottomCta";
@@ -16,6 +16,20 @@ export interface DmShortLinkRedirectConfig {
16
16
  posthogHostEnv?: string;
17
17
  }
18
18
 
19
+ /**
20
+ * Bot User-Agent regex. Bots (Twitter card prefetch, LinkedIn unfurl,
21
+ * Slack/Discord/Telegram/WhatsApp link-preview crawlers, generic Google/Bing
22
+ * bots) hammer /r/<code> within seconds of mint to fetch link previews. They
23
+ * inflated the legacy `clicks` counter by ~20x. When this regex matches, the
24
+ * resolver:
25
+ * 1. Skips the counter increment (passes `?bot=1` so the server side
26
+ * records the hit in post_link_clicks / dm_link_clicks with is_bot=true
27
+ * but does NOT bump post_links.clicks / dm_links.clicks).
28
+ * 2. Skips the PostHog `*_short_link_clicked` event (no fake conversions).
29
+ * 3. Still 302s to target_url so previews render.
30
+ */
31
+ const BOT_UA_RE = /bot|crawler|spider|Twitterbot|LinkedInBot|Slackbot|facebookexternalhit|Discordbot|TelegramBot|WhatsApp|Applebot|Googlebot|Bingbot|YandexBot|DuckDuckBot|redditbot|Pinterest|Embedly|Snapchat/i;
32
+
19
33
  /**
20
34
  * Factory for `GET /r/[code]`.
21
35
  *
@@ -34,12 +48,17 @@ export interface DmShortLinkRedirectConfig {
34
48
  *
35
49
  * Behavior:
36
50
  * 1. Read `code` from the route param. Reject non-alphanumeric / wrong-length.
37
- * 2. Hit `<resolverBase>/api/short-links/<code>`. The resolver increments the
38
- * click counter and stamps first/last click timestamps.
39
- * 3. For post rail links: inject UTM params into the target URL.
40
- * 4. Fire the appropriate PostHog event (dm_short_link_clicked or
41
- * post_short_link_clicked) fire-and-forget, non-blocking.
42
- * 5. 302 to the resolved target_url. On miss/error, 302 to "/".
51
+ * 2. Read User-Agent. If it matches BOT_UA_RE, pass `?bot=1` to the resolver
52
+ * so it logs the hit but does NOT bump the human-facing counter, and
53
+ * skip the PostHog event.
54
+ * 3. Hit `<resolverBase>/api/short-links/<code>?bot=<0|1>`. The resolver
55
+ * always appends a row to post_link_clicks / dm_link_clicks (with
56
+ * is_bot stamped accordingly). It only increments the legacy clicks
57
+ * counter and stamps first/last click timestamps when is_bot=false.
58
+ * 4. For post rail links: inject UTM params into the target URL.
59
+ * 5. Fire the appropriate PostHog event (dm_short_link_clicked or
60
+ * post_short_link_clicked) fire-and-forget, non-blocking; skipped for bots.
61
+ * 6. 302 to the resolved target_url. On miss/error, 302 to "/".
43
62
  */
44
63
  export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConfig) {
45
64
  const {
@@ -62,6 +81,14 @@ export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConf
62
81
  return Response.redirect(homeUrl, 302);
63
82
  }
64
83
 
84
+ // Bot detection lives at the edge so the server side can split humans vs
85
+ // bots in post_link_clicks / dm_link_clicks. Every UTF-8 string the user
86
+ // agent presents passes through this; matched UAs do NOT count as a real
87
+ // click and do NOT fire PostHog events.
88
+ const ua = req.headers.get("user-agent") || "";
89
+ const isBot = BOT_UA_RE.test(ua);
90
+ const referrer = req.headers.get("referer") || "";
91
+
65
92
  let target: string | null = null;
66
93
  let dmId: number | null = null;
67
94
  let postId: number | null = null;
@@ -70,7 +97,19 @@ export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConf
70
97
  let platform: string | null = null;
71
98
 
72
99
  try {
73
- const resp = await fetch(`${RESOLVER}/api/short-links/${encodeURIComponent(code)}`, {
100
+ const params = new URLSearchParams();
101
+ if (isBot) params.set("bot", "1");
102
+ // Forward UA + referrer so the server can persist them in
103
+ // *_link_clicks. We pass them through query params (not headers)
104
+ // because Next.js fetch normalizes some headers and the resolver
105
+ // is a separate origin. Truncate to keep the URL manageable.
106
+ if (ua) params.set("ua", ua.slice(0, 500));
107
+ if (referrer) params.set("ref", referrer.slice(0, 500));
108
+ const qs = params.toString();
109
+ const resolverUrl =
110
+ `${RESOLVER}/api/short-links/${encodeURIComponent(code)}` +
111
+ (qs ? `?${qs}` : "");
112
+ const resp = await fetch(resolverUrl, {
74
113
  cache: "no-store",
75
114
  signal: AbortSignal.timeout(4000),
76
115
  });
@@ -118,8 +157,10 @@ export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConf
118
157
  const posthogKey = process.env[posthogKeyEnv];
119
158
  const posthogHost = (process.env[posthogHostEnv] || "https://us.i.posthog.com").replace(/\/+$/, "");
120
159
 
160
+ // Skip both PostHog events for bots: they do not represent intent, and
161
+ // counting them as `*_short_link_clicked` would taint funnel stats.
121
162
  // DM rail event
122
- if (target && posthogKey && dmId != null) {
163
+ if (!isBot && target && posthogKey && dmId != null) {
123
164
  fetch(`${posthogHost}/i/v0/e/`, {
124
165
  method: "POST",
125
166
  headers: { "Content-Type": "application/json" },
@@ -136,7 +177,7 @@ export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConf
136
177
  }
137
178
 
138
179
  // Post rail event
139
- if (target && posthogKey && (postId != null || replyId != null)) {
180
+ if (!isBot && target && posthogKey && (postId != null || replyId != null)) {
140
181
  fetch(`${posthogHost}/i/v0/e/`, {
141
182
  method: "POST",
142
183
  headers: { "Content-Type": "application/json" },