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
7 changes: 7 additions & 0 deletions apps/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,13 @@ time.undated {
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.25rem;
/* A tag strip is a list whether it is written as one or not. Without these,
`<ul class="tags">` keeps the browser's discs and its 40px indent, while
the `<span>` spelling next to it does not — the same component rendering
two ways depending on the markup somebody reached for. */
list-style: none;
padding-left: 0;
margin-left: 0;
}
.tag {
font-size: 0.78rem;
Expand Down
160 changes: 122 additions & 38 deletions apps/web/src/lib/automotive.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { config } from '@nichedb/config';
import * as auto from '@nichedb/db/automotive';

/**
Expand Down Expand Up @@ -541,47 +542,130 @@ export function maintenanceSchedule({
* a catalogue it does not have. It builds the searches a person would type,
* with the vehicle already in them, at the places that actually stock parts.
*/
export function partsSearches({ year, make, model, part = '' }) {
const q = (s) => encodeURIComponent(String(s).trim());

/**
* The vendors, and which of them will pay for a referral.
*
* `program` is what was actually found when this was checked (2026-09-06), not
* what an affiliate-directory site claims: three of the six run a program and
* three do not, and saying so keeps anyone from hunting for a RockAuto link id
* that has never existed.
*/
export const PARTS_VENDORS = [
{
key: 'rockauto',
vendor: 'RockAuto',
kind: 'catalogue',
note: 'Cheapest for most wear parts; catalogue is by year/make/model.',
program: null,
url: ({ year, make, model }) =>
`https://www.rockauto.com/en/catalog/${q(make.toLowerCase())},${year},${q(model.toLowerCase())}`,
},
{
key: 'ebay',
vendor: 'eBay Motors',
kind: 'marketplace',
note: 'New, used and OEM take-offs. Filter by "fits your vehicle".',
program: { network: 'eBay Partner Network', signup: 'https://partnernetwork.ebay.com/' },
url: ({ withPart }) => `https://www.ebay.com/sch/6028/i.html?_nkw=${q(withPart)}`,
},
{
key: 'autozone',
vendor: 'AutoZone',
kind: 'retail',
note: 'Same-day pickup in the US.',
program: { network: 'CJ Affiliate', signup: 'https://www.cj.com/' },
url: ({ withPart }) => `https://www.autozone.com/searchresult?searchText=${q(withPart)}`,
},
{
key: 'oreilly',
vendor: "O'Reilly Auto Parts",
kind: 'retail',
program: null,
url: ({ withPart }) => `https://www.oreillyauto.com/search?q=${q(withPart)}`,
},
{
key: 'napa',
vendor: 'NAPA',
kind: 'retail',
program: {
network: 'Rakuten Advertising',
signup: 'https://www.napaonline.com/en/affiliate-program',
},
url: ({ withPart }) => `https://www.napaonline.com/en/search?text=${q(withPart)}`,
},
{
key: 'carpart',
vendor: 'Car-Part.com',
kind: 'salvage',
note: 'Recycled and salvage-yard inventory: the only realistic source for body and interior parts on an older car.',
program: null,
url: () => 'https://www.car-part.com/',
},
];

/**
* Affiliate links, without a code change per network.
*
* Every one of these networks builds a tracking link the same way: their own
* URL with the real destination encoded inside it. So rather than teach this
* file the shape of CJ, Rakuten and EPN links — and get one of them subtly
* wrong — a deployment supplies the template its network gave it and this puts
* the destination in the hole:
*
* AFFILIATE_LINKS="ebay=https://rover.ebay.com/rover/1/711-53200-19255-0/1?mpre={url}&campid=5338XXXXXX,
* napa=https://click.linksynergy.com/deeplink?id=ID&mid=MID&murl={url}"
*
* A vendor with no template is linked to plainly, which is every vendor until
* somebody is actually approved.
*/
export function parseAffiliateTemplates(spec) {
const out = new Map();
for (const pair of String(spec ?? '').split(',')) {
const at = pair.indexOf('=');
if (at < 1) continue;
const key = pair.slice(0, at).trim().toLowerCase();
const template = pair.slice(at + 1).trim();
if (!key || !template.includes('{url}')) continue;
if (!/^https:\/\//i.test(template)) continue;
out.set(key, template);
}
return out;
}

export function applyAffiliate(key, url, templates) {
const template = templates?.get?.(key);
if (!template) return { url, sponsored: false };
return { url: template.replaceAll('{url}', encodeURIComponent(url)), sponsored: true };
}

/**
* Where to get the part.
*
* Fitment data — which part number fits which vehicle — is the Auto Care
* Association's ACES/PIES, and it is licensed per seat. So this does not claim
* a catalogue it does not have. It builds the searches a person would type,
* with the vehicle already in them, at the places that actually stock parts.
*/
export function partsSearches({ year, make, model, part = '', affiliates = null }) {
if (!year || !make || !model) return [];
const vehicle = `${year} ${make} ${model}`;
const q = (s) => encodeURIComponent(s.trim());
const withPart = part ? `${vehicle} ${part}` : vehicle;
return [
{
vendor: 'RockAuto',
kind: 'catalogue',
note: 'Cheapest for most wear parts; catalogue is by year/make/model.',
url: `https://www.rockauto.com/en/catalog/${q(make.toLowerCase())},${year},${q(model.toLowerCase())}`,
},
{
vendor: 'eBay Motors',
kind: 'marketplace',
note: 'New, used and OEM take-offs. Filter by "fits your vehicle".',
url: `https://www.ebay.com/sch/6028/i.html?_nkw=${q(withPart)}`,
},
{
vendor: 'AutoZone',
kind: 'retail',
note: 'Same-day pickup in the US.',
url: `https://www.autozone.com/searchresult?searchText=${q(withPart)}`,
},
{
vendor: "O'Reilly Auto Parts",
kind: 'retail',
url: `https://www.oreillyauto.com/search?q=${q(withPart)}`,
},
{
vendor: 'NAPA',
kind: 'retail',
url: `https://www.napaonline.com/en/search?text=${q(withPart)}`,
},
{
vendor: 'Car-Part.com',
kind: 'salvage',
note: 'Recycled and salvage-yard inventory: the only realistic source for body and interior parts on an older car.',
url: `https://www.car-part.com/`,
},
];
const templates = affiliates ?? parseAffiliateTemplates(config.automotive.affiliateLinks);
return PARTS_VENDORS.map((v) => {
const plain = v.url({ year, make, model, vehicle, withPart });
const { url, sponsored } = applyAffiliate(v.key, plain, templates);
return {
vendor: v.vendor,
kind: v.kind,
...(v.note ? { note: v.note } : {}),
url,
// Said in the payload, not just in the markup: anything reading this
// through the API needs to know which links pay us.
sponsored,
};
});
}

/* -------------------------------------------------------------- mechanics -- */
Expand Down
21 changes: 19 additions & 2 deletions apps/web/src/views/automotive.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,31 @@ const Profile = ({ profile }) => {
<h3>Parts</h3>
<ul class="tags">
{profile.parts.searches.map((p) => (
<li key={p.vendor} class="tag">
<a href={p.url} rel="noopener nofollow">
<li key={p.vendor}>
{/* The tag IS the link, as everywhere else on the site. A `.tag`
wrapping a link instead gives a pill with link-coloured text
inside it, which is a second look for the same component. */}
{/* `sponsored` is required by Google and by the FTC on a link
that pays us, and it is only true once somebody has actually
been approved for that vendor's programme. */}
<a
class="tag"
href={p.url}
rel={p.sponsored ? 'sponsored nofollow noopener' : 'nofollow noopener'}
title={p.note ?? p.kind}
>
{p.vendor}
</a>
</li>
))}
</ul>
<p class="small muted">{profile.parts.note}</p>
{profile.parts.searches.some((p) => p.sponsored) ? (
<p class="small muted">
Some of these links pay us a commission if you buy through them. It costs you nothing
and it does not change which vendors are listed or their order.
</p>
) : null}
</section>

<section>
Expand Down
8 changes: 8 additions & 0 deletions packages/config/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ export const config = {
monthlyCents: num('AUTOMOTIVE_MONTHLY_CENTS', 3000),
/** Straight-line miles a mechanics search may cover. */
maxRadiusMiles: num('AUTOMOTIVE_MAX_RADIUS_MILES', 50),
/**
* Affiliate tracking links for the parts vendors, as
* `vendor=template,vendor=template`, where the template holds `{url}` and
* the destination is url-encoded into it. Every network builds links that
* way, so a new one needs no code — only the template it gave you. Empty
* by default: until somebody is approved, every parts link is a plain one.
*/
affiliateLinks: opt('AFFILIATE_LINKS'),
},

/** CrawlProof ads on the free tier: the publisher slot pages and feeds fill from. */
Expand Down
55 changes: 55 additions & 0 deletions test/automotive.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { describe, expect, test } from 'bun:test';
process.env.DATABASE_URL ??= 'postgres://test:test@localhost:5432/test';
process.env.SITE_URL ??= 'https://nichedb.test';
const {
applyAffiliate,
checkDigit,
checkDigitOk,
closestModel,
compactDecode,
maintenanceSchedule,
milesBetween,
normaliseVin,
parseAffiliateTemplates,
partsSearches,
powertrainOf,
vinModelYear,
Expand Down Expand Up @@ -126,6 +128,59 @@ describe('parts and places', () => {
expect(searches.some((s) => s.vendor === 'RockAuto')).toBe(true);
});

test('a plain link is the default: nothing is sponsored until it is', () => {
const searches = partsSearches({
year: 2014,
make: 'Honda',
model: 'Civic',
affiliates: new Map(),
});
expect(searches.every((s) => s.sponsored === false)).toBe(true);
expect(searches.find((s) => s.vendor === 'eBay Motors').url).toStartWith(
'https://www.ebay.com/',
);
});

test('a template wraps the destination and marks the link sponsored', () => {
const templates = parseAffiliateTemplates(
'ebay=https://rover.ebay.com/rover/1/711-53200-19255-0/1?mpre={url}&campid=5338999999',
);
const searches = partsSearches({
year: 2014,
make: 'Honda',
model: 'Civic',
part: 'alternator',
affiliates: templates,
});
const ebay = searches.find((s) => s.vendor === 'eBay Motors');
expect(ebay.sponsored).toBe(true);
expect(ebay.url).toStartWith('https://rover.ebay.com/');
expect(ebay.url).toContain('campid=5338999999');
// The real destination survives, encoded, inside the tracking link.
expect(decodeURIComponent(ebay.url.split('mpre=')[1].split('&')[0])).toContain('alternator');
// A vendor with no template is untouched and unmarked.
expect(searches.find((s) => s.vendor === 'NAPA').sponsored).toBe(false);
});

test('a template that could not track or could not be trusted is ignored', () => {
// No {url} hole: it would send every buyer to the same page.
expect(parseAffiliateTemplates('ebay=https://rover.ebay.com/no-hole').size).toBe(0);
// Not https.
expect(parseAffiliateTemplates('ebay=http://rover.ebay.com/?u={url}').size).toBe(0);
// Junk between the commas does not take the rest of the list down with it.
const ok = parseAffiliateTemplates('nonsense,napa=https://click.linksynergy.com/?murl={url}');
expect(ok.size).toBe(1);
expect(ok.has('napa')).toBe(true);
});

test('applyAffiliate leaves an unknown vendor alone', () => {
const t = parseAffiliateTemplates('ebay=https://rover.ebay.com/?mpre={url}');
expect(applyAffiliate('rockauto', 'https://www.rockauto.com/x', t)).toEqual({
url: 'https://www.rockauto.com/x',
sponsored: false,
});
});

test('an incomplete vehicle gets no searches rather than broken ones', () => {
expect(partsSearches({ year: 2014, make: 'Honda' })).toEqual([]);
});
Expand Down
Loading