Imported from OliverBirch/gaming-gear-affiliate (
AGENTS.md). Install upstream withnpx skills add OliverBirch/gaming-gear-affiliate. Copyright stays with the author.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.
ProSetups.dk — Project conventions
Core focus
Answer one question: "What gear does this pro use, where can I buy it in Denmark, and is this info current?" Pro data is the moat; product categories serve the pro-data.
Roadmap
ROADMAP.md tracks project phases (platform → pro data → real affiliate integration → full retailer coverage → launch) and a dated log of milestone sessions. After a session that finishes or meaningfully advances a phase, update it: flip the phase status and append one line to the Log. Don't put per-product counts there — that's what /admin and /admin/tickets are for; they're computed from data and won't drift like a hand-maintained count would.
Source of truth
- Zod schemas in
src/lib/types.tsare canonical. Data files must conform — transform layers map raw JSON to schemas. - Never edit raw data inline; create a transform layer (see
src/data/mousepads.tsfor the pattern). Mice and keyboards deliberately diverge from that pattern's offer-building — see thebuild-offers.tsbullet below before "fixing" them to match. - Every data module runs its schema at import time (
for (const x of built) XSchema.parse(x)), includingpros.ts. A build failure here means the data is wrong — fix the data, don't loosen the schema. - Never invent
sidstVerificeretorkilde. They are nullable; leave them null when unknown so/adminreports "aldrig verificeret". A default date silently claims a verification that never happened.
Shared modules — use these, don't re-inline
src/lib/schema-org.ts— all JSON-LD.breadcrumbList,productItemList,productSchema,jsonLd, plusSITE_URL/absoluteUrl. Hand-writing a"@context"literal in a page is how/headsetended up with nobrandorurlin its ItemList.src/lib/product-labels.ts— Danish enum labels. NoteprisNiveauLabels("Mellemklasse", for spec tables) andprisNiveauLabelsShort("Mellem", for card badges) are both intentional; don't collapse them.src/lib/affiliate.ts—getLowestPrice(product). Don't re-inlinereduce(…, Infinity).src/lib/utils.ts—formatPriceDkk(n)for the"N kr."suffix.src/data/build-offers.ts—buildFlatOffersfor flat{retailer: price}maps (headsets, monitors). Payout rates differ per category by design: headsets pay elgiganten 2.5%, monitors 3.5%. Tests pin this. Mousepads keep their own builder — nested per-size prices.- Mice and keyboards don't use
buildFlatOffersat all — by design, not oversight.mice.jsonstores each mouse'soffersarray verbatim: individually-authored per-product URLs (produktUrl/affiliateUrl), not a generic category-search fallback. These are not confirmed live links — the site has no affiliate program hooked up yet, every outbound URL across the site is placeholder/provisional pending real verification — but they're still per-product, not machine-generated from a shared template, and that's what matters for the JSON format choice. Routing them through aprisermap +buildFlatOfferswould silently collapse per-product URLs into generic category search URLs across all 52 mice — don't do it, regardless of the URLs' current verification status. Keyboards are the opposite case: every entry's offers were already machine-generated from a retailer list (kbOffers()inkeyboards.ts), sokeyboards.jsonstores that retailer list ("retailers": [...]) andkbOffers()expands it at build time — don't store the expanded offer objects there either, that would just duplicate generated data.
Tests
npm test(Vitest). Coverage is deliberately narrow: affiliate offer/price resolution, the peripheral-matching false-positive traps, schema-org output.- Peripheral matching is fuzzy substring matching over free text and is the most fragile logic in the repo. Order is load-bearing —
"cloud iii"contains"cloud ii", so the III branch must be checked first. Add a regression test with any newmatch()rule.
Product copy (CopyPoints)
- Prices belong in
offers[].prisDkkorprices.jsonoverrides. Do NOT write price strings ($180,~$200,399 kr.) intofordele/ulemperorbeskrivelse. The CopyPoints Zod refine rejects$+ digit patterns at validation.
Retailers
RETAILER_SLUGSis a closed Zod enum. Adding a retailer requires:- Update the union in
types.ts - Add a matching entry in
retailers.tswith real payout data - Do NOT add a slug before the retailer entry exists
- Update the union in
Route naming
- Danish for routes and UI:
/tastaturer,/musemaatter,/maerke,/mus. English for code identifiers.
Data workflows
- Use the
add-proskill to add pros — it handles pros.ts, peripherals, images, and stub mouse creation. - Use the
add-mouseskill to create mice or complete stubs — it sources specs from RTINGS, copy from reviews, and offers from affiliate portals. - Unknown mice encountered during pro creation are tracked in
src/data/mice-todo.ts. Visit/admin/todoto see the backlog. - Pro gear source is always
prosettings.net. Mouse specs come from RTINGS → Techpowerup → manufacturer. - Pro team/hold source is Liquipedia API (
src/lib/liquipedia.ts), not prosettings.net. Liquipedia is more accurate for roster tracking with full transfer history and covers CS2, Valorant, and R6. add-headsetskill mirrorsadd-mousebut for headsets (same flow: research specs → write Danish copy → find a real per-product retailer URL → add to JSON). MaxGaming is not a retailer partner — removed from the catalog 2026-08-08, do not source offers, prices, or images from maxgaming.dk.- Incomplete data tickets: When creating pros or products with missing data (no image, stub mouse, no offers, missing peripherals), append a
FreshnessTickettosrc/data/freshness-tasks.ts. Tickets appear in/admin/ticketsfor manual follow-up. See the ticket types:missing-pro-image,stub-mouse-created,no-mouse-offers,peripheral-missing. - Delegating small jobs: Small, mechanical, self-contained jobs (boilerplate, a standalone script, a repetitive data transform) can be offloaded to the user's opencode/deepseek-v4-pro subscription instead of Claude usage — see the
delegate-deepseekskill. Always confirm with the user first; deepseek is review-only, never given write access.
Team logos
- Team logos are sourced from ProSettings.net CDN (
prosettings.net/wp-content/uploads/{slug}.svgor.png). - Download via
node scripts/fetch-team-logos.mjs. Run after adding new teams or when logos appear stale. src/data/team-logos.tsmaps normalized hold names → local file paths inpublic/images/teams/.- Missing teams fall back to monogram placeholders from
scripts/_gen-team-monograms.mjs. - To add a logo: find the team's ProSettings slug from
https://prosettings.net/teams/{slug}/, add the mapping tofetch-team-logos.mjsandteam-logos.ts, then re-run the fetch script.
Liquipedia API for team data
Liquipedia is the primary source for pro team/roster data. Use the fetch utility in src/lib/liquipedia.ts:
- CS2:
https://liquipedia.net/counterstrike/api.php?action=parse&page={slug}&format=json&prop=text§ion=0 - Valorant:
https://liquipedia.net/valorant/api.php?action=parse&page={slug}&format=json&prop=text§ion=0 - R6:
https://liquipedia.net/rainbowsix/api.php?action=parse&page={slug}&format=json&prop=text§ion=0
The infobox contains structured Team:, Status:, Nationality:, and History fields. Liquipedia content is CC-BY-SA licensed — credit via the kilde field alongside prosettings.net.
Peripheral mapping gotchas
src/data/pros-peripherals-mapping.tsuses substring matching to link free-text peripheral names to catalog slugs.- Substring matching on short/common terms is too greedy:
"rog"matches both "ROG Delta II" and "ROG Pelta" (different headsets). A match on"delta"+"rog"is better than"rog"alone. "cloud ii"matches "HyperX Cloud II" but also "HyperX Cloud Stinger II" (wrong product). Prefer matching"cloud ii"+ excluding known false positives with!includes("stinger").- Always verify a handful of actual pro entries after adding a new pattern to catch false matches.
Adding a new product category
When adding a new peripheral category (e.g. headsets, monitors) to the site:
- Schema —
HeadsetSchema(orMonitorSchema) already exists insrc/lib/types.ts— skip this step - Data file — Create
src/data/{category}.jsonwith raw entries (prices in apriserkey), thensrc/data/{category}.tstransform layer (modeled onsrc/data/mousepads.tspattern — map raw JSON → typed array, buildoffersfrompriser, wireproBrugerevia mapping) - Card component — Create
src/components/{category}-card.tsx(modeled onsrc/components/headset-card.tsx) - List route — Create
src/app/{category}/page.tsxwith metadata, grid of cards, Schema.org markup - Detail route — Create
src/app/{category}/[slug]/page.tsxwith spec table, fordele/ulemper, price comparison, pro list, generateStaticParams, Schema.org Product + BreadcrumbList - Mapping — Add
match{Category}+get{Category}Slug/get{Category}ProSlugsfunctions tosrc/data/pros-peripherals-mapping.ts - Brand pages — Add section to
src/app/maerke/[slug]/page.tsx(filter brand, render cards, link to list) - Pro pages — Wire the slug into
src/app/pro/[slug]/page.tsxso the free-text links to the catalog detail page - Navigation — Add dropdown entry to
src/components/site-header.tsx(both desktop and mobile) - Sitemap — Add list page + all detail pages to
src/app/sitemap.ts - Dashboard — Add stat card to
src/app/admin/page.tsx
Generation
- All content is statically generated at build time. Use
generateStaticParamsfor dynamic routes. No runtime data fetching.
Affiliate links
- Use
rel="sponsored nofollow"on all outbound affiliate links. - Route through
/api/redirectfor click logging. - Prices resolve via: static
offers[].prisDkk→prices.jsonoverrides → no price shown.
Data freshness (weekly cadence)
Keeping pro gear data and prices current is the site's primary value. Follow this routine:
Weekly checklist (15–40 min)
- Open
/admin— scan high-severity issues - Fix auto-fixable: run
fix-issuesskill forbroken-url,missing-image,missing-pro-image - Re-verify stale pros: run
re-verify-prosskill — priority tier-1 first (src/data/freshness-priority.ts), then oldest stale from admin - Complete 1 stub mouse if any exist in
mice-todo.ts(useadd-mouseskill) - Spot-check prices: update top 5 mouse prices into
prices.jsonif feed isn't live yet - Run
npm run build— verifies Zod validation passes
When admin flags issues
| Issue type | Severity | Skill |
|---|---|---|
stale-pro |
high/med | re-verify-pros |
stub-mouse |
high | add-mouse |
no-offers |
high | Research product + add offers |
broken-url |
high | fix-issues (auto-fixable) |
missing-price |
medium | prices.json override |
feed-stale |
medium | Refresh prices.json.scrapedAt |
orphaned-mus |
high | add-mouse (create missing mouse) |
missing-image |
low | fix-issues (auto-fixable) |
empty-description / empty-fordele / empty-ulemper |
medium | add-mouse |
missing-peripherals |
low | add-pro step 3 |
product-stale |
low | Re-verify product specs |
unmapped-peripheral |
low | Add match() rule in mapping |
Validation layers
- TypeScript —
next buildchecks all types - Zod parse — Runs at module level in
mice.ts,keyboards.ts,mousepads.ts,headsets.ts(build fails if data doesn't match schema) - CopyPoints — Zod refine rejects
$+ digit infordele/ulemper/beskrivelse - JSON validation —
npm run validate-datachecksprices.json, CopyPoints in JSON files, required fields - Admin health —
/admingenerates a full issue report at build time (SSG)
Key reference files for freshness
src/lib/data-health.ts— Pure health-check functions (stale, offers, prices, copy, stubs, mapping)src/data/freshness-priority.ts— Tier-1 CS2/Valorant/R6 priority listsscripts/validate-data.mjs— Standalone JSON validation (CopyPoints, schema checks)scripts/scrape.mjs— Deprecated (wrong schema, DO NOT USE).opencode/skills/re-verify-pros/SKILL.md— Batch re-verify workflow