From 82fbd316818e911d078006db1c3bdee8c4b40497 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 11:41:28 +0000 Subject: [PATCH] Route outbound links through a referral endpoint where we have one Adds Dedirock's referral link and the machinery to use it. The table of deals lives in packages/core/src/affiliates.js rather than in the providers table, because that table is a mirror: sync upserts it with coalesce(excluded.x, providers.x) on every column, so a referral URL stored there would arrive as null on each sync and survive only by the grace of that coalesce. In source it is reviewable in a diff, and a provider who is not in the catalogue yet is already wired up for the day they appear -- which is the case here, since nothing in the hosting collection names Dedirock today. The awkward part is that Dedirock's endpoint is WHMCS aff.php, measured on 2026-09-25: it answers 301 to https://dedirock.com, sets WHMCSAffiliateID for ninety days, and ignores both `url` and `goto`. Passing aff=960 inline on a billing page sets no cookie at all. So there is no way to earn the referral and land on a named plan. Rather than take the commission anyway, a landing-only link replaces a link to the provider's front door -- where nothing is lost, because that is where it lands regardless -- and never replaces a deep link to a particular offer. Dropping somebody who clicked a named plan onto a homepage to earn a commission is how a directory stops being worth reading. Every affiliate link is labelled where a reader can see it and carries rel="sponsored". Incidentally that is now accurate: the order button previously declared sponsored on every provider, paid or not. 20 tests for the substitution rule, the deep-link guard and the subdomain match (notdedirock.com must not borrow the deal). Full suite green at 59 pass / 2 skip. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/public/styles.css | 15 ++++ apps/web/src/views/parts.jsx | 31 +++++++ apps/web/src/views/providers.jsx | 10 +-- apps/web/src/views/servers.jsx | 18 ++-- packages/core/src/affiliates.js | 107 +++++++++++++++++++++++ packages/core/src/index.js | 1 + test/affiliates.test.js | 141 +++++++++++++++++++++++++++++++ 7 files changed, 305 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/affiliates.js create mode 100644 test/affiliates.test.js diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css index ee12079..d7916aa 100644 --- a/apps/web/public/styles.css +++ b/apps/web/public/styles.css @@ -157,6 +157,21 @@ pre code { .small { font-size: 0.82rem; } +/* + * The visible half of an affiliate disclosure. It sits inside the link it is + * disclosing, on a plain text link and on the primary order button alike, so + * it inherits colour and steps down only in size and weight. + */ +.aff-mark { + margin-left: 0.4em; + font-size: 0.7em; + font-weight: 400; + letter-spacing: 0.04em; + text-transform: uppercase; + opacity: 0.7; + vertical-align: 0.08em; + cursor: help; +} .visually-hidden, .skip { position: absolute; diff --git a/apps/web/src/views/parts.jsx b/apps/web/src/views/parts.jsx index 23c3c57..2c20e79 100644 --- a/apps/web/src/views/parts.jsx +++ b/apps/web/src/views/parts.jsx @@ -7,6 +7,7 @@ import { fmtGb, fmtMoney, KIND_LABELS, + outbound, paramsFrom, toggle, toggleBucket, @@ -16,6 +17,36 @@ import { raw } from 'hono/html'; /** Shared pieces: the rack-unit row, facet rail, chips, agent panel, sparkline. */ +/** + * A link off the site to a provider. + * + * Routed through a referral endpoint where we have a deal with that provider, + * and labelled as one when it is, because a reader is entitled to know which + * links pay us. `rel` carries `sponsored` in that case, which is what search + * engines ask for on a paid link. + */ +export function Outbound({ url, domain, class: cls, children }) { + const out = outbound(url, domain); + if (!out.href) return null; + return ( + + {children} + {out.affiliate ? : null} + + ); +} + +export function AffiliateMark() { + return ( + + affiliate + + ); +} + export const flag = (code) => /^[A-Z]{2}$/.test(code) ? String.fromCodePoint(...[...code].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)) diff --git a/apps/web/src/views/providers.jsx b/apps/web/src/views/providers.jsx index 664d9a2..1090af6 100644 --- a/apps/web/src/views/providers.jsx +++ b/apps/web/src/views/providers.jsx @@ -1,7 +1,7 @@ import { config } from '@r4ck/config'; import { countryName, fmtMoney } from '@r4ck/core'; import { Layout } from './Layout.jsx'; -import { AgentPanel, Automation, flag, initials, ProviderCard, Unit } from './parts.jsx'; +import { AgentPanel, Automation, flag, initials, Outbound, ProviderCard, Unit } from './parts.jsx'; const csv = (v) => String(v ?? '') @@ -249,11 +249,9 @@ export function Provider({ user, detail }) {