961tech Month 1 Localhost Prototype Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a localhost-runnable Next.js prototype that scrapes 3 Lebanese PC parts retailers (PCAndParts, 961Souq, Macrotronics) for CPU/GPU/Motherboard listings, displays a product comparison UI, tracks outbound clicks, and offers a 2-step compatibility-checked build wizard (CPU → Motherboard).
Architecture: Next.js 15 App Router monolith with Postgres (Prisma) and Redis (BullMQ) running via Docker Compose. Scrapers use undici + cheerio (HTTP-only, no Playwright in M1). Compatibility rules are deterministic TypeScript modules with Vitest unit tests. No auth, no LLM extraction, no S2S postback in M1 — those are M2/M3 concerns.
Tech Stack: Next.js 15.x + React 19 + TypeScript 5.x + Tailwind v4 + shadcn/ui + Prisma 6.x + Postgres 17 + Redis 7 + BullMQ + undici + cheerio + Vitest + Docker Compose
Working directory: C:\Users\USER\Desktop\Claude\961tech\ (created in Task 1)
Spec reference: Specs → 2026-04-25 aggregator design
File Structure¶
961tech/
├── docker-compose.yml # Postgres 17 + Redis 7 for local dev
├── .env.local # DATABASE_URL, REDIS_URL (gitignored)
├── .env.example # Template committed to git
├── .gitignore # node_modules, .env.local, .next, etc.
├── README.md # Setup + run instructions
├── package.json
├── tsconfig.json
├── next.config.ts
├── tailwind.config.ts
├── postcss.config.mjs
├── vitest.config.ts # Vitest setup
├── prisma/
│ ├── schema.prisma # Models: Retailer, Product, Listing, ListingPrice, Click
│ └── seed.ts # Seed retailers + canonical products
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout with Tailwind + nav
│ │ ├── page.tsx # Landing
│ │ ├── globals.css
│ │ ├── products/
│ │ │ ├── page.tsx # List with category filter
│ │ │ └── [slug]/
│ │ │ └── page.tsx # Detail with price comparison
│ │ ├── build/
│ │ │ ├── page.tsx # Wizard step 1 (CPU)
│ │ │ └── motherboard/
│ │ │ └── page.tsx # Wizard step 2 (MB filtered by socket)
│ │ └── api/
│ │ └── go/
│ │ └── r/[retailerId]/p/[listingId]/route.ts # Click → 302
│ ├── lib/
│ │ ├── db.ts # Prisma client singleton
│ │ └── matching.ts # Listing → canonical product fuzzy match
│ ├── rules/
│ │ ├── types.ts # RuleResult, Severity
│ │ ├── cpuMotherboard.ts # Socket compatibility rule
│ │ └── index.ts # Barrel export
│ ├── scrapers/
│ │ ├── runner.ts # BullMQ worker entrypoint
│ │ ├── core/
│ │ │ ├── http.ts # undici fetch with retries + UA
│ │ │ ├── parse.ts # cheerio helpers
│ │ │ └── normalize.ts # Brand/model canonicalization
│ │ └── sites/
│ │ ├── pcandparts.ts # listProducts(), getProduct(url)
│ │ ├── souq961.ts
│ │ └── macrotronics.ts
│ ├── components/
│ │ ├── ProductCard.tsx
│ │ ├── PriceTable.tsx
│ │ └── BuildWizard.tsx
│ └── types/
│ └── index.ts
└── tests/
├── rules/
│ └── cpuMotherboard.test.ts
├── scrapers/
│ ├── fixtures/ # Saved HTML samples for snapshot tests
│ ├── pcandparts.test.ts
│ ├── souq961.test.ts
│ └── macrotronics.test.ts
└── lib/
└── matching.test.ts
Task 1: Project Initialization¶
Files:
- Create: C:\Users\USER\Desktop\Claude\961tech\ (new directory)
- Create: 961tech/package.json, 961tech/.gitignore, 961tech/README.md
- Step 1: Create project directory and initialize git
Expected: Initialized empty Git repository in C:/Users/USER/Desktop/Claude/961tech/.git/
- Step 2: Scaffold Next.js 15 with TypeScript + Tailwind
npx create-next-app@latest . --typescript --tailwind --app --src-dir --import-alias "@/*" --use-npm --eslint --yes
Expected: Success! Created 961tech at /c/Users/USER/Desktop/Claude/961tech. Verifies that src/app/page.tsx, src/app/layout.tsx, and src/app/globals.css exist.
- Step 3: Install runtime dependencies
npm install prisma @prisma/client undici cheerio nanoid bullmq ioredis zod
npm install -D vitest @vitest/ui tsx @types/node ts-node
Expected: added N packages for both commands.
- Step 4: Initialize Prisma
Expected: Creates prisma/schema.prisma and .env.
- Step 5: Update .gitignore
Append to 961tech/.gitignore:
# Local env
.env
.env.local
.env.*.local
# Database
*.db
*.db-journal
# Vitest
coverage/
# OS
.DS_Store
Thumbs.db
- Step 6: Initial commit
Expected: Commit created with all scaffolded files.
Task 2: Docker Compose for Postgres + Redis¶
Files:
- Create: 961tech/docker-compose.yml
- Create: 961tech/.env.example
- Modify: 961tech/.env (local-only)
- Step 1: Create docker-compose.yml
Create 961tech/docker-compose.yml:
version: '3.9'
services:
postgres:
image: postgres:17-alpine
container_name: 961tech-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: tech961
ports:
- "5433:5432" # host 5433 → container 5432 (avoid conflict with hummingbot-postgres on host 5432)
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: 961tech-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
- Step 2: Create .env.example
Create 961tech/.env.example:
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/tech961?schema=public"
REDIS_URL="redis://localhost:6379"
- Step 3: Update .env with local values
Replace contents of 961tech/.env:
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/tech961?schema=public"
REDIS_URL="redis://localhost:6379"
- Step 4: Start Docker services and verify
Expected: Both 961tech-postgres and 961tech-redis show status healthy (wait ~10s if "starting").
- Step 5: Verify Postgres connection
Expected: PostgreSQL 17.x version string.
- Step 6: Commit
git add docker-compose.yml .env.example .gitignore
git commit -m "feat: add docker-compose for postgres + redis"
Task 3: Prisma Schema + Initial Migration¶
Files:
- Modify: 961tech/prisma/schema.prisma
- Create: 961tech/prisma/migrations/ (auto-generated)
- Create: 961tech/src/lib/db.ts
- Step 1: Replace prisma/schema.prisma with the full schema
Replace contents of 961tech/prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Category {
CPU
GPU
MOTHERBOARD
RAM
PSU
CASE
COOLER
STORAGE
LAPTOP
PREBUILT
}
model Retailer {
id String @id @default(uuid())
name String @unique
slug String @unique
domain String
scraperId String @unique
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings Listing[]
clicks Click[]
}
model Product {
id String @id @default(uuid())
category Category
brand String
model String
slug String @unique
// Compat-relevant typed columns
cpuSocket String? // 'AM5', 'LGA1851', etc.
ramType String? // 'DDR5', 'DDR4'
ramSpeedMhz Int?
formFactor String? // 'ATX', 'mATX', 'ITX'
tdpWatts Int?
lengthMm Int?
heightMm Int?
psuWattage Int?
// JSONB for long-tail specs
specs Json @default("{}")
imageUrl String?
releaseDate DateTime?
msrpUsd Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings Listing[]
@@index([category])
@@index([category, cpuSocket])
@@index([brand, model])
}
model Listing {
id String @id @default(uuid())
retailerId String
productId String? // null until matched
url String
retailerSku String?
titleRaw String
matchConfidence Float?
matchStatus String @default("unmatched") // 'auto', 'manual', 'unmatched'
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
retailer Retailer @relation(fields: [retailerId], references: [id])
product Product? @relation(fields: [productId], references: [id])
prices ListingPrice[]
clicks Click[]
@@unique([retailerId, url])
@@index([productId])
@@index([matchStatus])
}
model ListingPrice {
id String @id @default(uuid())
listingId String
priceUsd Decimal? @db.Decimal(10, 2)
inStock Boolean @default(true)
scrapedAt DateTime @default(now())
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
@@index([listingId, scrapedAt(sort: Desc)])
}
model Click {
id String @id @default(uuid())
clickToken String @unique
retailerId String
listingId String
source String // 'product_page', 'wizard', 'cart_optimizer'
ipHash String?
ua String?
referrer String?
createdAt DateTime @default(now())
retailer Retailer @relation(fields: [retailerId], references: [id])
listing Listing @relation(fields: [listingId], references: [id])
@@index([retailerId, createdAt(sort: Desc)])
@@index([listingId, createdAt(sort: Desc)])
}
- Step 2: Run initial migration
Expected: Creates prisma/migrations/<timestamp>_init/migration.sql and applies it. Your database is now in sync with your schema.
- Step 3: Generate Prisma client
Expected: Generated Prisma Client (vN.N.N) to ./node_modules/@prisma/client
- Step 4: Create Prisma client singleton at src/lib/db.ts
Create 961tech/src/lib/db.ts:
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
- Step 5: Verify schema applied
Expected: Lists tables Retailer, Product, Listing, ListingPrice, Click, plus _prisma_migrations.
- Step 6: Commit
git add prisma/ src/lib/db.ts
git commit -m "feat: add prisma schema with retailer/product/listing/click models"
Task 4: Vitest Setup + Rules Type Framework¶
Files:
- Create: 961tech/vitest.config.ts
- Create: 961tech/src/rules/types.ts
- Create: 961tech/src/rules/index.ts
- Modify: 961tech/package.json (add test scripts)
- Step 1: Create vitest.config.ts
Create 961tech/vitest.config.ts:
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
environment: 'node',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
- Step 2: Add test scripts to package.json
In 961tech/package.json, add to the "scripts" object:
- Step 3: Create rules type definitions
Create 961tech/src/rules/types.ts:
export type Severity = 'block' | 'warn' | 'info';
export interface RuleResult {
ok: boolean;
severity: Severity;
reason?: string;
ruleId: string;
}
export interface CompatibilityCheck {
results: RuleResult[];
overallOk: boolean;
blockingIssues: RuleResult[];
warnings: RuleResult[];
}
export function aggregate(results: RuleResult[]): CompatibilityCheck {
const blockingIssues = results.filter((r) => !r.ok && r.severity === 'block');
const warnings = results.filter((r) => r.severity === 'warn' && r.reason);
return {
results,
overallOk: blockingIssues.length === 0,
blockingIssues,
warnings,
};
}
- Step 4: Create rules barrel export
Create 961tech/src/rules/index.ts:
(The barrel will fail to compile until Task 5 creates cpuMotherboard.ts. That's OK — we'll fix it in Task 5.)
- Step 5: Verify Vitest installed correctly
Expected: Vitest version number (e.g., 2.1.x or 3.x).
- Step 6: Commit
git add vitest.config.ts src/rules/types.ts src/rules/index.ts package.json package-lock.json
git commit -m "feat: add vitest config and rules type framework"
Task 5: CPU↔Motherboard Socket Compatibility Rule (TDD)¶
Files:
- Create: 961tech/tests/rules/cpuMotherboard.test.ts
- Create: 961tech/src/rules/cpuMotherboard.ts
- Step 1: Write the failing test
Create 961tech/tests/rules/cpuMotherboard.test.ts:
import { describe, it, expect } from 'vitest';
import { cpuFitsMotherboard } from '@/rules/cpuMotherboard';
describe('cpuFitsMotherboard', () => {
it('returns ok=true when sockets match', () => {
const cpu = { brand: 'AMD', model: 'Ryzen 7 7800X3D', cpuSocket: 'AM5' };
const mb = { brand: 'ASUS', model: 'ROG STRIX X670E-E', cpuSocket: 'AM5' };
const result = cpuFitsMotherboard(cpu, mb);
expect(result.ok).toBe(true);
expect(result.severity).toBe('info');
expect(result.ruleId).toBe('cpu-motherboard-socket');
});
it('returns ok=false with blocking severity when sockets differ', () => {
const cpu = { brand: 'Intel', model: 'i9-14900K', cpuSocket: 'LGA1700' };
const mb = { brand: 'MSI', model: 'MAG B650 TOMAHAWK', cpuSocket: 'AM5' };
const result = cpuFitsMotherboard(cpu, mb);
expect(result.ok).toBe(false);
expect(result.severity).toBe('block');
expect(result.reason).toContain('LGA1700');
expect(result.reason).toContain('AM5');
});
it('returns warn severity when CPU socket is null', () => {
const cpu = { brand: 'Unknown', model: 'X', cpuSocket: null };
const mb = { brand: 'ASUS', model: 'Y', cpuSocket: 'AM5' };
const result = cpuFitsMotherboard(cpu, mb);
expect(result.ok).toBe(true);
expect(result.severity).toBe('warn');
expect(result.reason).toContain('socket data missing');
});
it('returns warn severity when motherboard socket is null', () => {
const cpu = { brand: 'AMD', model: 'X', cpuSocket: 'AM5' };
const mb = { brand: 'ASUS', model: 'Y', cpuSocket: null };
const result = cpuFitsMotherboard(cpu, mb);
expect(result.ok).toBe(true);
expect(result.severity).toBe('warn');
expect(result.reason).toContain('socket data missing');
});
});
- Step 2: Run test to verify it fails
Expected: All 4 tests FAIL with "Failed to resolve import @/rules/cpuMotherboard".
- Step 3: Write the minimal implementation
Create 961tech/src/rules/cpuMotherboard.ts:
import type { RuleResult } from './types';
export interface CpuLike {
brand: string;
model: string;
cpuSocket: string | null;
}
export interface MotherboardLike {
brand: string;
model: string;
cpuSocket: string | null;
}
export function cpuFitsMotherboard(cpu: CpuLike, mb: MotherboardLike): RuleResult {
const ruleId = 'cpu-motherboard-socket';
if (!cpu.cpuSocket || !mb.cpuSocket) {
return {
ok: true,
severity: 'warn',
ruleId,
reason: 'CPU or motherboard socket data missing — compatibility unverified',
};
}
if (cpu.cpuSocket !== mb.cpuSocket) {
return {
ok: false,
severity: 'block',
ruleId,
reason: `CPU socket ${cpu.cpuSocket} does not match motherboard socket ${mb.cpuSocket}`,
};
}
return {
ok: true,
severity: 'info',
ruleId,
};
}
- Step 4: Run tests to verify they pass
Expected: All 4 tests PASS.
- Step 5: Commit
git add tests/rules/cpuMotherboard.test.ts src/rules/cpuMotherboard.ts
git commit -m "feat: add CPU↔motherboard socket compatibility rule with tests"
Task 6: Scraper Core — HTTP Fetcher + Parsing Helpers¶
Files:
- Create: 961tech/src/scrapers/core/http.ts
- Create: 961tech/src/scrapers/core/parse.ts
- Create: 961tech/src/scrapers/core/normalize.ts
- Create: 961tech/tests/scrapers/core/normalize.test.ts
- Step 1: Write failing test for normalize.ts
Create 961tech/tests/scrapers/core/normalize.test.ts:
import { describe, it, expect } from 'vitest';
import { normalizeBrandModel, normalizePrice } from '@/scrapers/core/normalize';
describe('normalizeBrandModel', () => {
it('uppercases brand and trims whitespace from model', () => {
expect(normalizeBrandModel('amd', ' Ryzen 7 7800X3D ')).toEqual({
brand: 'AMD',
model: 'Ryzen 7 7800X3D',
});
});
it('handles "Intel Core" prefix variations', () => {
expect(normalizeBrandModel('Intel', 'Core i9-14900K')).toEqual({
brand: 'INTEL',
model: 'i9-14900K',
});
expect(normalizeBrandModel('Intel', 'i9-14900K')).toEqual({
brand: 'INTEL',
model: 'i9-14900K',
});
});
it('strips parenthetical extras from model', () => {
expect(normalizeBrandModel('AMD', 'Ryzen 7 7800X3D (Box)')).toEqual({
brand: 'AMD',
model: 'Ryzen 7 7800X3D',
});
});
});
describe('normalizePrice', () => {
it('parses USD with $ sign', () => {
expect(normalizePrice('$549.99')).toBe(549.99);
});
it('parses USD with thousands separator', () => {
expect(normalizePrice('$1,899.00')).toBe(1899);
});
it('parses bare numeric', () => {
expect(normalizePrice('549')).toBe(549);
});
it('returns null for "Call for price" or invalid', () => {
expect(normalizePrice('Call for price')).toBeNull();
expect(normalizePrice('')).toBeNull();
expect(normalizePrice('TBD')).toBeNull();
});
});
- Step 2: Run test to verify it fails
Expected: FAIL with "Failed to resolve import".
- Step 3: Implement normalize.ts
Create 961tech/src/scrapers/core/normalize.ts:
export function normalizeBrandModel(
brand: string,
model: string
): { brand: string; model: string } {
const cleanBrand = brand.trim().toUpperCase();
let cleanModel = model.trim();
// Strip "Core " prefix from Intel models
if (cleanBrand === 'INTEL') {
cleanModel = cleanModel.replace(/^Core\s+/i, '');
}
// Strip parenthetical extras: "(Box)", "(Tray)", etc.
cleanModel = cleanModel.replace(/\s*\([^)]*\)\s*$/, '').trim();
return { brand: cleanBrand, model: cleanModel };
}
export function normalizePrice(input: string): number | null {
if (!input) return null;
const cleaned = input.replace(/[$,\s]/g, '');
const num = parseFloat(cleaned);
if (isNaN(num) || num <= 0) return null;
return num;
}
- Step 4: Run tests to verify they pass
Expected: All 7 tests PASS.
- Step 5: Implement http.ts (no test — wraps undici)
Create 961tech/src/scrapers/core/http.ts:
import { fetch } from 'undici';
const USER_AGENT = '961techBot/1.0 (+https://961tech.com/bot)';
export interface FetchOptions {
retries?: number;
timeoutMs?: number;
}
export async function fetchHtml(
url: string,
options: FetchOptions = {}
): Promise<string> {
const { retries = 2, timeoutMs = 15000 } = options;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9,ar;q=0.8',
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return await response.text();
} catch (err) {
lastError = err as Error;
if (attempt < retries) {
// Exponential backoff: 1s, 2s, 4s
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
}
throw new Error(`Failed to fetch ${url} after ${retries + 1} attempts: ${lastError?.message}`);
}
- Step 6: Implement parse.ts (cheerio helpers, no test — pure pass-through)
Create 961tech/src/scrapers/core/parse.ts:
import * as cheerio from 'cheerio';
export type Cheerio = ReturnType<typeof cheerio.load>;
export function load(html: string): Cheerio {
return cheerio.load(html);
}
export function textOf($el: ReturnType<Cheerio>): string {
return $el.first().text().trim();
}
export function attrOf($el: ReturnType<Cheerio>, attr: string): string | undefined {
return $el.first().attr(attr);
}
- Step 7: Commit
git add tests/scrapers/core/normalize.test.ts src/scrapers/core/
git commit -m "feat: add scraper core (http fetch + parse + normalize)"
Task 7: PCAndParts CPU Scraper¶
Files:
- Create: 961tech/tests/scrapers/fixtures/pcandparts-cpu-listing.html (saved sample)
- Create: 961tech/tests/scrapers/pcandparts.test.ts
- Create: 961tech/src/scrapers/sites/pcandparts.ts
- Step 1: Save a fixture HTML sample
Manually fetch https://pcandparts.com/product-category/computer-components/processors/ in a browser, save the page source to 961tech/tests/scrapers/fixtures/pcandparts-cpu-listing.html.
If the URL is unreachable or layout changes, the fallback fixture content for testing purposes:
<!DOCTYPE html>
<html>
<body>
<ul class="products">
<li class="product">
<a href="https://pcandparts.com/product/amd-ryzen-7-7800x3d/" class="woocommerce-LoopProduct-link">
<h2 class="woocommerce-loop-product__title">AMD Ryzen 7 7800X3D</h2>
<span class="price">
<span class="woocommerce-Price-amount">$549.00</span>
</span>
</a>
</li>
<li class="product outofstock">
<a href="https://pcandparts.com/product/intel-i9-14900k/" class="woocommerce-LoopProduct-link">
<h2 class="woocommerce-loop-product__title">Intel Core i9-14900K</h2>
<span class="price">
<span class="woocommerce-Price-amount">$629.00</span>
</span>
</a>
</li>
</ul>
</body>
</html>
Save that as 961tech/tests/scrapers/fixtures/pcandparts-cpu-listing.html.
- Step 2: Write failing test
Create 961tech/tests/scrapers/pcandparts.test.ts:
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { parseCpuListings } from '@/scrapers/sites/pcandparts';
const fixture = readFileSync(
join(__dirname, 'fixtures', 'pcandparts-cpu-listing.html'),
'utf-8'
);
describe('PCAndParts: parseCpuListings', () => {
it('extracts at least 2 CPU listings from the fixture', () => {
const listings = parseCpuListings(fixture);
expect(listings.length).toBeGreaterThanOrEqual(2);
});
it('returns title, url, and price for each listing', () => {
const listings = parseCpuListings(fixture);
const first = listings[0];
expect(first.titleRaw).toBeTruthy();
expect(first.url).toMatch(/^https?:\/\//);
expect(first.priceUsd === null || typeof first.priceUsd === 'number').toBe(true);
});
it('detects out-of-stock from CSS class', () => {
const listings = parseCpuListings(fixture);
const outOfStock = listings.find((l) => l.titleRaw.includes('14900K'));
expect(outOfStock?.inStock).toBe(false);
});
it('marks in-stock items as inStock=true', () => {
const listings = parseCpuListings(fixture);
const inStock = listings.find((l) => l.titleRaw.includes('7800X3D'));
expect(inStock?.inStock).toBe(true);
});
});
- Step 3: Run test to verify it fails
Expected: FAIL with "Failed to resolve import".
- Step 4: Implement pcandparts.ts
Create 961tech/src/scrapers/sites/pcandparts.ts:
import { load } from '@/scrapers/core/parse';
import { normalizePrice } from '@/scrapers/core/normalize';
import { fetchHtml } from '@/scrapers/core/http';
export interface ScrapedListing {
url: string;
titleRaw: string;
priceUsd: number | null;
inStock: boolean;
}
export const RETAILER_ID = 'pcandparts';
const BASE = 'https://pcandparts.com';
const CATEGORY_URLS = {
CPU: `${BASE}/product-category/computer-components/processors/`,
GPU: `${BASE}/product-category/computer-components/graphic-cards/`,
MOTHERBOARD: `${BASE}/product-category/computer-components/motherboards/`,
};
export function parseCpuListings(html: string): ScrapedListing[] {
const $ = load(html);
const listings: ScrapedListing[] = [];
$('li.product').each((_, el) => {
const $el = $(el);
const $link = $el.find('a.woocommerce-LoopProduct-link').first();
const url = $link.attr('href');
const title = $el.find('.woocommerce-loop-product__title').first().text().trim();
const priceText = $el.find('.woocommerce-Price-amount').first().text().trim();
if (!url || !title) return;
listings.push({
url,
titleRaw: title,
priceUsd: normalizePrice(priceText),
inStock: !$el.hasClass('outofstock'),
});
});
return listings;
}
export async function fetchCpuListings(): Promise<ScrapedListing[]> {
const html = await fetchHtml(CATEGORY_URLS.CPU);
return parseCpuListings(html);
}
- Step 5: Run tests to verify they pass
Expected: All 4 tests PASS.
- Step 6: Commit
git add tests/scrapers/fixtures/pcandparts-cpu-listing.html tests/scrapers/pcandparts.test.ts src/scrapers/sites/pcandparts.ts
git commit -m "feat: add PCAndParts CPU listings scraper with tests"
Task 8: 961Souq CPU Scraper¶
Files:
- Create: 961tech/tests/scrapers/fixtures/souq961-cpu-listing.html
- Create: 961tech/tests/scrapers/souq961.test.ts
- Create: 961tech/src/scrapers/sites/souq961.ts
- Step 1: Save fixture (Shopify format)
Manually fetch a CPU category page from https://961souq.com/collections/processors (or similar). Save to 961tech/tests/scrapers/fixtures/souq961-cpu-listing.html.
Fallback fixture (Shopify uses different markup):
<!DOCTYPE html>
<html>
<body>
<div class="grid">
<div class="grid__item product-card">
<a href="/products/amd-ryzen-9-7950x" class="card__media">
<img src="..." alt="">
</a>
<div class="card__content">
<a href="/products/amd-ryzen-9-7950x">
<h3 class="card__heading">AMD Ryzen 9 7950X</h3>
</a>
<div class="price">
<span class="price-item price-item--regular">$699.00</span>
</div>
</div>
</div>
<div class="grid__item product-card sold-out">
<a href="/products/intel-i7-14700k" class="card__media">
<img src="..." alt="">
</a>
<div class="card__content">
<a href="/products/intel-i7-14700k">
<h3 class="card__heading">Intel Core i7-14700K</h3>
</a>
<div class="price">
<span class="price-item price-item--regular">$425.00</span>
</div>
<span class="badge badge--sold-out">Sold out</span>
</div>
</div>
</div>
</body>
</html>
- Step 2: Write failing test
Create 961tech/tests/scrapers/souq961.test.ts:
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { parseCpuListings } from '@/scrapers/sites/souq961';
const fixture = readFileSync(
join(__dirname, 'fixtures', 'souq961-cpu-listing.html'),
'utf-8'
);
describe('961Souq: parseCpuListings', () => {
it('extracts at least 2 CPU listings', () => {
const listings = parseCpuListings(fixture);
expect(listings.length).toBeGreaterThanOrEqual(2);
});
it('produces absolute URLs', () => {
const listings = parseCpuListings(fixture);
listings.forEach((l) => {
expect(l.url).toMatch(/^https:\/\/961souq\.com\//);
});
});
it('detects sold-out items', () => {
const listings = parseCpuListings(fixture);
const soldOut = listings.find((l) => l.titleRaw.includes('14700K'));
expect(soldOut?.inStock).toBe(false);
});
});
- Step 3: Run test to verify it fails
Expected: FAIL.
- Step 4: Implement souq961.ts
Create 961tech/src/scrapers/sites/souq961.ts:
import { load } from '@/scrapers/core/parse';
import { normalizePrice } from '@/scrapers/core/normalize';
import { fetchHtml } from '@/scrapers/core/http';
import type { ScrapedListing } from './pcandparts';
export const RETAILER_ID = '961souq';
const BASE = 'https://961souq.com';
const CATEGORY_URLS = {
CPU: `${BASE}/collections/processors`,
GPU: `${BASE}/collections/graphics-cards`,
MOTHERBOARD: `${BASE}/collections/motherboards`,
};
export function parseCpuListings(html: string): ScrapedListing[] {
const $ = load(html);
const listings: ScrapedListing[] = [];
$('.product-card').each((_, el) => {
const $el = $(el);
const linkHref = $el.find('a').first().attr('href');
const title = $el.find('.card__heading').first().text().trim();
const priceText = $el.find('.price-item--regular').first().text().trim();
const isSoldOut = $el.hasClass('sold-out') || $el.find('.badge--sold-out').length > 0;
if (!linkHref || !title) return;
const url = linkHref.startsWith('http') ? linkHref : `${BASE}${linkHref}`;
listings.push({
url,
titleRaw: title,
priceUsd: normalizePrice(priceText),
inStock: !isSoldOut,
});
});
return listings;
}
export async function fetchCpuListings(): Promise<ScrapedListing[]> {
const html = await fetchHtml(CATEGORY_URLS.CPU);
return parseCpuListings(html);
}
- Step 5: Run tests to verify they pass
Expected: All 3 tests PASS.
- Step 6: Commit
git add tests/scrapers/fixtures/souq961-cpu-listing.html tests/scrapers/souq961.test.ts src/scrapers/sites/souq961.ts
git commit -m "feat: add 961Souq CPU listings scraper with tests"
Task 9: Macrotronics CPU Scraper¶
Files:
- Create: 961tech/tests/scrapers/fixtures/macrotronics-cpu-listing.html
- Create: 961tech/tests/scrapers/macrotronics.test.ts
- Create: 961tech/src/scrapers/sites/macrotronics.ts
- Step 1: Save fixture
Fetch from https://www.macrotronics.net/collections/processors (Shopify-based). Save to 961tech/tests/scrapers/fixtures/macrotronics-cpu-listing.html.
If unreachable, reuse the Shopify-style fixture from Task 8 with paths adjusted to https://www.macrotronics.net/products/....
- Step 2: Write failing test
Create 961tech/tests/scrapers/macrotronics.test.ts:
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { parseCpuListings } from '@/scrapers/sites/macrotronics';
const fixture = readFileSync(
join(__dirname, 'fixtures', 'macrotronics-cpu-listing.html'),
'utf-8'
);
describe('Macrotronics: parseCpuListings', () => {
it('extracts at least 1 CPU listing', () => {
const listings = parseCpuListings(fixture);
expect(listings.length).toBeGreaterThanOrEqual(1);
});
it('produces absolute macrotronics.net URLs', () => {
const listings = parseCpuListings(fixture);
listings.forEach((l) => {
expect(l.url).toMatch(/^https:\/\/www\.macrotronics\.net\//);
});
});
it('returns price as number or null', () => {
const listings = parseCpuListings(fixture);
listings.forEach((l) => {
expect(l.priceUsd === null || typeof l.priceUsd === 'number').toBe(true);
});
});
});
- Step 3: Run test to verify it fails
Expected: FAIL.
- Step 4: Implement macrotronics.ts (Shopify-pattern)
Create 961tech/src/scrapers/sites/macrotronics.ts:
import { load } from '@/scrapers/core/parse';
import { normalizePrice } from '@/scrapers/core/normalize';
import { fetchHtml } from '@/scrapers/core/http';
import type { ScrapedListing } from './pcandparts';
export const RETAILER_ID = 'macrotronics';
const BASE = 'https://www.macrotronics.net';
const CATEGORY_URLS = {
CPU: `${BASE}/collections/processors`,
GPU: `${BASE}/collections/graphics-cards`,
MOTHERBOARD: `${BASE}/collections/motherboards`,
};
export function parseCpuListings(html: string): ScrapedListing[] {
const $ = load(html);
const listings: ScrapedListing[] = [];
$('.product-card, .grid__item').each((_, el) => {
const $el = $(el);
const linkHref = $el.find('a[href*="/products/"]').first().attr('href');
const title = $el.find('.card__heading, .product-card__title, h3').first().text().trim();
const priceText = $el.find('.price-item--regular, .price__current, .money').first().text().trim();
const isSoldOut = $el.hasClass('sold-out') || $el.find('.badge--sold-out, .sold-out').length > 0;
if (!linkHref || !title) return;
const url = linkHref.startsWith('http') ? linkHref : `${BASE}${linkHref}`;
listings.push({
url,
titleRaw: title,
priceUsd: normalizePrice(priceText),
inStock: !isSoldOut,
});
});
return listings;
}
export async function fetchCpuListings(): Promise<ScrapedListing[]> {
const html = await fetchHtml(CATEGORY_URLS.CPU);
return parseCpuListings(html);
}
- Step 5: Run tests to verify they pass
Expected: All 3 tests PASS.
- Step 6: Run all tests to verify nothing else broke
Expected: All tests across all files PASS.
- Step 7: Commit
git add tests/scrapers/fixtures/macrotronics-cpu-listing.html tests/scrapers/macrotronics.test.ts src/scrapers/sites/macrotronics.ts
git commit -m "feat: add Macrotronics CPU listings scraper with tests"
Task 10: Seed Retailers + Canonical CPU Catalog¶
Files:
- Create: 961tech/prisma/seed.ts
- Modify: 961tech/package.json (add prisma seed config)
- Step 1: Create seed.ts with retailers + ~30 canonical CPUs
Create 961tech/prisma/seed.ts:
import { PrismaClient, Category } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Retailers
const retailers = [
{ name: 'PC and Parts', slug: 'pcandparts', domain: 'pcandparts.com', scraperId: 'pcandparts' },
{ name: '961Souq', slug: '961souq', domain: '961souq.com', scraperId: '961souq' },
{ name: 'Macrotronics', slug: 'macrotronics', domain: 'macrotronics.net', scraperId: 'macrotronics' },
];
for (const r of retailers) {
await prisma.retailer.upsert({
where: { slug: r.slug },
create: r,
update: r,
});
}
// Canonical CPUs (AMD AM5)
const am5Cpus = [
{ brand: 'AMD', model: 'Ryzen 9 7950X', socket: 'AM5', tdp: 170 },
{ brand: 'AMD', model: 'Ryzen 9 7950X3D', socket: 'AM5', tdp: 120 },
{ brand: 'AMD', model: 'Ryzen 9 7900X', socket: 'AM5', tdp: 170 },
{ brand: 'AMD', model: 'Ryzen 9 7900X3D', socket: 'AM5', tdp: 120 },
{ brand: 'AMD', model: 'Ryzen 7 7800X3D', socket: 'AM5', tdp: 120 },
{ brand: 'AMD', model: 'Ryzen 7 7700X', socket: 'AM5', tdp: 105 },
{ brand: 'AMD', model: 'Ryzen 7 7700', socket: 'AM5', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 7600X', socket: 'AM5', tdp: 105 },
{ brand: 'AMD', model: 'Ryzen 5 7600', socket: 'AM5', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 7500F', socket: 'AM5', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 9 9950X', socket: 'AM5', tdp: 170 },
{ brand: 'AMD', model: 'Ryzen 9 9900X', socket: 'AM5', tdp: 120 },
{ brand: 'AMD', model: 'Ryzen 7 9700X', socket: 'AM5', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 9600X', socket: 'AM5', tdp: 65 },
];
// AMD AM4 (legacy still selling)
const am4Cpus = [
{ brand: 'AMD', model: 'Ryzen 7 5800X3D', socket: 'AM4', tdp: 105 },
{ brand: 'AMD', model: 'Ryzen 7 5700X', socket: 'AM4', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 5600X', socket: 'AM4', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 5600', socket: 'AM4', tdp: 65 },
];
// Intel LGA1700 (12th, 13th, 14th gen)
const lga1700Cpus = [
{ brand: 'Intel', model: 'i9-14900K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i9-14900KF', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i7-14700K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i7-14700KF', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i5-14600K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i5-14400F', socket: 'LGA1700', tdp: 65 },
{ brand: 'Intel', model: 'i9-13900K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i7-13700K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i5-13600K', socket: 'LGA1700', tdp: 125 },
];
// Intel LGA1851 (Core Ultra Series 2)
const lga1851Cpus = [
{ brand: 'Intel', model: 'Core Ultra 9 285K', socket: 'LGA1851', tdp: 125 },
{ brand: 'Intel', model: 'Core Ultra 7 265K', socket: 'LGA1851', tdp: 125 },
{ brand: 'Intel', model: 'Core Ultra 5 245K', socket: 'LGA1851', tdp: 125 },
];
const allCpus = [...am5Cpus, ...am4Cpus, ...lga1700Cpus, ...lga1851Cpus];
for (const cpu of allCpus) {
const slug = `${cpu.brand}-${cpu.model}`.toLowerCase().replace(/[^a-z0-9]+/g, '-');
await prisma.product.upsert({
where: { slug },
create: {
category: Category.CPU,
brand: cpu.brand,
model: cpu.model,
slug,
cpuSocket: cpu.socket,
tdpWatts: cpu.tdp,
},
update: {},
});
}
console.log(`Seeded ${retailers.length} retailers, ${allCpus.length} CPUs`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
- Step 2: Add prisma seed config to package.json
In 961tech/package.json, add at the top level (sibling of "scripts"):
- Step 3: Run the seed
Expected: Seeded 3 retailers, 30 CPUs
- Step 4: Verify in psql
docker exec -it 961tech-postgres psql -U postgres -d tech961 -c "SELECT COUNT(*) FROM \"Product\" WHERE category = 'CPU';"
Expected: count = 30 (or close — count matches the number of CPUs in the seed file).
- Step 5: Commit
Task 11: Listing-to-Product Matching Logic¶
Files:
- Create: 961tech/tests/lib/matching.test.ts
- Create: 961tech/src/lib/matching.ts
- Step 1: Write failing test
Create 961tech/tests/lib/matching.test.ts:
import { describe, it, expect } from 'vitest';
import { matchListingToProduct } from '@/lib/matching';
const products = [
{ id: 'p1', brand: 'AMD', model: 'Ryzen 7 7800X3D' },
{ id: 'p2', brand: 'AMD', model: 'Ryzen 9 7950X' },
{ id: 'p3', brand: 'Intel', model: 'i9-14900K' },
{ id: 'p4', brand: 'Intel', model: 'i7-14700K' },
];
describe('matchListingToProduct', () => {
it('matches exact brand + model', () => {
const result = matchListingToProduct('AMD Ryzen 7 7800X3D', products);
expect(result.productId).toBe('p1');
expect(result.confidence).toBeGreaterThanOrEqual(0.95);
});
it('matches Intel "Core" prefix variation', () => {
const result = matchListingToProduct('Intel Core i9-14900K', products);
expect(result.productId).toBe('p3');
expect(result.confidence).toBeGreaterThanOrEqual(0.85);
});
it('matches with Box/Tray suffix stripped', () => {
const result = matchListingToProduct('AMD Ryzen 9 7950X (Box)', products);
expect(result.productId).toBe('p2');
});
it('returns low confidence with no match for unknown product', () => {
const result = matchListingToProduct('AMD Ryzen 5 1600AF', products);
expect(result.productId).toBeNull();
expect(result.confidence).toBeLessThan(0.5);
});
});
- Step 2: Run test to verify it fails
Expected: FAIL.
- Step 3: Implement matching.ts
Create 961tech/src/lib/matching.ts:
import { normalizeBrandModel } from '@/scrapers/core/normalize';
export interface ProductLike {
id: string;
brand: string;
model: string;
}
export interface MatchResult {
productId: string | null;
confidence: number;
}
/**
* Tokenize and lowercase a string for comparison.
*/
function tokenize(s: string): string[] {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.split(/\s+/)
.filter(Boolean);
}
/**
* Compute similarity as token-overlap ratio (intersection / union).
*/
function tokenOverlap(a: string, b: string): number {
const setA = new Set(tokenize(a));
const setB = new Set(tokenize(b));
const intersection = new Set([...setA].filter((x) => setB.has(x)));
const union = new Set([...setA, ...setB]);
return intersection.size / union.size;
}
export function matchListingToProduct(
listingTitle: string,
products: ProductLike[]
): MatchResult {
// Try to extract brand from the title (first word usually)
const firstWord = listingTitle.split(/\s+/)[0]?.toUpperCase() || '';
const candidates = products.filter((p) => p.brand.toUpperCase() === firstWord);
// If no brand match, fall back to all products
const pool = candidates.length > 0 ? candidates : products;
let best: { product: ProductLike; score: number } | null = null;
for (const product of pool) {
// Normalize the listing title with the assumed brand to handle "Intel Core" → "i9-..."
const { model: normalizedModel } = normalizeBrandModel(product.brand, listingTitle);
const score = tokenOverlap(normalizedModel, product.model);
if (!best || score > best.score) {
best = { product, score };
}
}
if (!best || best.score < 0.5) {
return { productId: null, confidence: best?.score ?? 0 };
}
return { productId: best.product.id, confidence: best.score };
}
- Step 4: Run tests to verify they pass
Expected: All 4 tests PASS.
- Step 5: Commit
git add tests/lib/matching.test.ts src/lib/matching.ts
git commit -m "feat: add fuzzy listing-to-product matching"
Task 12: Root Layout + Landing Page¶
Files:
- Modify: 961tech/src/app/layout.tsx
- Modify: 961tech/src/app/page.tsx
- Modify: 961tech/src/app/globals.css (verify Tailwind v4 directives)
- Step 1: Replace src/app/layout.tsx with branded layout
Replace contents of 961tech/src/app/layout.tsx:
import type { Metadata } from 'next';
import './globals.css';
import Link from 'next/link';
export const metadata: Metadata = {
title: '961tech — Lebanese tech aggregator',
description: 'Compare PC parts and tech across Lebanese retailers.',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-zinc-950 text-zinc-100 antialiased">
<header className="border-b border-zinc-800 bg-zinc-900/50 backdrop-blur sticky top-0 z-10">
<nav className="mx-auto max-w-6xl px-4 py-3 flex items-center justify-between">
<Link href="/" className="text-xl font-bold tracking-tight">
<span className="text-cyan-400">961</span>tech
</Link>
<div className="flex gap-6 text-sm">
<Link href="/products?category=CPU" className="hover:text-cyan-400">Browse</Link>
<Link href="/build" className="hover:text-cyan-400">Build a PC</Link>
</div>
</nav>
</header>
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
<footer className="border-t border-zinc-800 mt-16 py-6 text-center text-xs text-zinc-500">
961tech prototype · {new Date().getFullYear()}
</footer>
</body>
</html>
);
}
- Step 2: Replace src/app/page.tsx with landing
Replace contents of 961tech/src/app/page.tsx:
import Link from 'next/link';
export default function HomePage() {
return (
<div className="space-y-12 py-12">
<section className="text-center space-y-4">
<h1 className="text-5xl font-bold tracking-tight">
Lebanese tech, <span className="text-cyan-400">finally compared</span>.
</h1>
<p className="text-lg text-zinc-400 max-w-2xl mx-auto">
Find the cheapest PC parts across PCAndParts, 961Souq, Macrotronics, and more.
Build a compatibility-checked PC. Click through to buy.
</p>
<div className="flex gap-4 justify-center pt-4">
<Link
href="/build"
className="bg-cyan-500 hover:bg-cyan-400 text-zinc-950 px-6 py-3 rounded-md font-semibold transition"
>
Build a PC →
</Link>
<Link
href="/products?category=CPU"
className="border border-zinc-700 hover:border-cyan-500 px-6 py-3 rounded-md font-semibold transition"
>
Browse parts
</Link>
</div>
</section>
<section className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-8">
<FeatureCard title="Compatibility-checked" body="The build wizard live-filters parts that won't fit." />
<FeatureCard title="Multi-retailer cart" body="See the cheapest split across stores. Save more, click less." />
<FeatureCard title="Lebanon-first" body="Real prices, real stock, from real Beirut retailers." />
</section>
</div>
);
}
function FeatureCard({ title, body }: { title: string; body: string }) {
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-6 hover:border-cyan-500/50 transition">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">{title}</h3>
<p className="text-sm text-zinc-400">{body}</p>
</div>
);
}
- Step 3: Run dev server to verify
Open http://localhost:3000 in a browser. Verify the landing page renders with cyan branding, "961tech" header, hero, and 3 feature cards. Stop the dev server with Ctrl+C.
- Step 4: Commit
Task 13: Product List Page (/products)¶
Files:
- Create: 961tech/src/app/products/page.tsx
- Create: 961tech/src/components/ProductCard.tsx
- Step 1: Create ProductCard component
Create 961tech/src/components/ProductCard.tsx:
import Link from 'next/link';
interface ProductCardProps {
slug: string;
brand: string;
model: string;
cheapestPriceUsd: number | null;
retailerCount: number;
socket?: string | null;
}
export function ProductCard({
slug,
brand,
model,
cheapestPriceUsd,
retailerCount,
socket,
}: ProductCardProps) {
return (
<Link
href={`/products/${slug}`}
className="block rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 hover:border-cyan-500/50 transition"
>
<div className="flex items-baseline justify-between mb-2">
<span className="text-xs uppercase tracking-wider text-zinc-500">{brand}</span>
{socket && (
<span className="text-xs font-mono text-cyan-400">{socket}</span>
)}
</div>
<h3 className="font-semibold text-zinc-100 mb-3 line-clamp-2">{model}</h3>
<div className="flex items-baseline justify-between">
<div>
{cheapestPriceUsd !== null ? (
<>
<span className="text-xs text-zinc-500">from</span>
<div className="text-xl font-bold text-cyan-400">
${cheapestPriceUsd.toFixed(2)}
</div>
</>
) : (
<span className="text-sm text-zinc-500">No active listings</span>
)}
</div>
<span className="text-xs text-zinc-500">
{retailerCount} retailer{retailerCount === 1 ? '' : 's'}
</span>
</div>
</Link>
);
}
- Step 2: Create the product list page
Create 961tech/src/app/products/page.tsx:
import { db } from '@/lib/db';
import { ProductCard } from '@/components/ProductCard';
import Link from 'next/link';
import { Category } from '@prisma/client';
export const dynamic = 'force-dynamic';
const CATEGORIES: { value: Category; label: string }[] = [
{ value: 'CPU', label: 'CPUs' },
{ value: 'GPU', label: 'GPUs' },
{ value: 'MOTHERBOARD', label: 'Motherboards' },
];
interface PageProps {
searchParams: Promise<{ category?: string }>;
}
export default async function ProductsPage({ searchParams }: PageProps) {
const params = await searchParams;
const category = (params.category as Category) || 'CPU';
const products = await db.product.findMany({
where: { category },
include: {
listings: {
where: { matchStatus: { in: ['auto', 'manual'] } },
include: {
prices: { orderBy: { scrapedAt: 'desc' }, take: 1 },
retailer: true,
},
},
},
orderBy: [{ brand: 'asc' }, { model: 'asc' }],
take: 100,
});
const enriched = products.map((p) => {
const validPrices = p.listings
.map((l) => l.prices[0]?.priceUsd)
.filter((x): x is NonNullable<typeof x> => x !== undefined && x !== null)
.map((d) => Number(d));
const cheapestPriceUsd = validPrices.length > 0 ? Math.min(...validPrices) : null;
const retailerCount = new Set(p.listings.map((l) => l.retailerId)).size;
return {
slug: p.slug,
brand: p.brand,
model: p.model,
cheapestPriceUsd,
retailerCount,
socket: p.cpuSocket,
};
});
return (
<div className="space-y-6">
<div className="flex items-baseline justify-between">
<h1 className="text-3xl font-bold">Browse parts</h1>
<span className="text-sm text-zinc-500">{enriched.length} results</span>
</div>
<div className="flex gap-2 flex-wrap">
{CATEGORIES.map((c) => (
<Link
key={c.value}
href={`/products?category=${c.value}`}
className={`px-4 py-2 rounded-md text-sm font-medium transition ${
c.value === category
? 'bg-cyan-500 text-zinc-950'
: 'bg-zinc-900 text-zinc-300 hover:bg-zinc-800'
}`}
>
{c.label}
</Link>
))}
</div>
{enriched.length === 0 ? (
<p className="text-zinc-500 py-12 text-center">No products yet. Run the scrapers to populate listings.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{enriched.map((p) => (
<ProductCard key={p.slug} {...p} />
))}
</div>
)}
</div>
);
}
- Step 3: Run dev server and verify
Open http://localhost:3000/products?category=CPU. Verify:
- Category tabs render at top with CPU highlighted
- Product cards render for the seeded CPUs (no listings yet — cards show "No active listings")
- Counter shows "30 results"
Stop dev server.
- Step 4: Commit
git add src/app/products/page.tsx src/components/ProductCard.tsx
git commit -m "feat: add product list page with category filter"
Task 14: Product Detail Page (/products/[slug])¶
Files:
- Create: 961tech/src/app/products/[slug]/page.tsx
- Create: 961tech/src/components/PriceTable.tsx
- Step 1: Create PriceTable component
Create 961tech/src/components/PriceTable.tsx:
interface PriceRow {
retailerId: string;
retailerName: string;
retailerDomain: string;
listingId: string;
url: string;
priceUsd: number | null;
inStock: boolean;
scrapedAt: Date;
}
interface PriceTableProps {
rows: PriceRow[];
}
export function PriceTable({ rows }: PriceTableProps) {
if (rows.length === 0) {
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-8 text-center text-zinc-500">
No listings yet. The scrapers haven't found this product on any retailer site.
</div>
);
}
// Sort: in-stock first, then by price ascending
const sorted = [...rows].sort((a, b) => {
if (a.inStock !== b.inStock) return a.inStock ? -1 : 1;
if (a.priceUsd === null) return 1;
if (b.priceUsd === null) return -1;
return a.priceUsd - b.priceUsd;
});
return (
<div className="rounded-lg border border-zinc-800 overflow-hidden">
<table className="w-full">
<thead className="bg-zinc-900 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-3 text-left">Retailer</th>
<th className="px-4 py-3 text-right">Price</th>
<th className="px-4 py-3 text-center">Stock</th>
<th className="px-4 py-3 text-right">Updated</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{sorted.map((row, idx) => (
<tr
key={row.listingId}
className={`border-t border-zinc-800 ${idx === 0 && row.inStock ? 'bg-cyan-950/20' : ''}`}
>
<td className="px-4 py-3">
<div className="font-medium">{row.retailerName}</div>
<div className="text-xs text-zinc-500">{row.retailerDomain}</div>
</td>
<td className="px-4 py-3 text-right font-mono">
{row.priceUsd !== null ? `$${row.priceUsd.toFixed(2)}` : '—'}
</td>
<td className="px-4 py-3 text-center">
{row.inStock ? (
<span className="inline-flex items-center rounded-full bg-green-900/40 px-2 py-0.5 text-xs text-green-300">
In stock
</span>
) : (
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-xs text-zinc-400">
Out of stock
</span>
)}
</td>
<td className="px-4 py-3 text-right text-xs text-zinc-500">
{timeAgo(row.scrapedAt)}
</td>
<td className="px-4 py-3 text-right">
<a
href={`/api/go/r/${row.retailerId}/p/${row.listingId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-block bg-cyan-500 hover:bg-cyan-400 text-zinc-950 px-3 py-1.5 rounded text-sm font-semibold transition"
>
Buy
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function timeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
- Step 2: Create the product detail page
Create 961tech/src/app/products/[slug]/page.tsx:
import { db } from '@/lib/db';
import { PriceTable } from '@/components/PriceTable';
import { notFound } from 'next/navigation';
export const dynamic = 'force-dynamic';
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function ProductPage({ params }: PageProps) {
const { slug } = await params;
const product = await db.product.findUnique({
where: { slug },
include: {
listings: {
include: {
retailer: true,
prices: { orderBy: { scrapedAt: 'desc' }, take: 1 },
},
},
},
});
if (!product) notFound();
const rows = product.listings.map((l) => ({
retailerId: l.retailer.id,
retailerName: l.retailer.name,
retailerDomain: l.retailer.domain,
listingId: l.id,
url: l.url,
priceUsd: l.prices[0]?.priceUsd ? Number(l.prices[0].priceUsd) : null,
inStock: l.prices[0]?.inStock ?? false,
scrapedAt: l.prices[0]?.scrapedAt ?? l.lastSeenAt,
}));
return (
<div className="space-y-8">
<div>
<span className="text-xs uppercase tracking-wider text-zinc-500">{product.brand}</span>
<h1 className="text-3xl font-bold mt-1">{product.model}</h1>
<div className="flex gap-3 mt-3 text-sm text-zinc-400">
<span className="rounded-md bg-zinc-900 px-2 py-1">{product.category}</span>
{product.cpuSocket && (
<span className="rounded-md bg-zinc-900 px-2 py-1 font-mono">
Socket: {product.cpuSocket}
</span>
)}
{product.tdpWatts && (
<span className="rounded-md bg-zinc-900 px-2 py-1">TDP: {product.tdpWatts}W</span>
)}
</div>
</div>
<section>
<h2 className="text-xl font-semibold mb-4">Available at</h2>
<PriceTable rows={rows} />
</section>
</div>
);
}
- Step 3: Run dev server and verify
Open http://localhost:3000/products/amd-ryzen-7-7800x3d. Verify:
- Page renders with brand label "AMD"
- Title "Ryzen 7 7800X3D"
- Tags showing "CPU", "Socket: AM5", "TDP: 120W"
- "Available at" section showing "No listings yet" message
Stop dev server.
- Step 4: Commit
git add src/app/products/[slug]/page.tsx src/components/PriceTable.tsx
git commit -m "feat: add product detail page with price comparison table"
Task 15: Outbound Click Tracking Endpoint¶
Files:
- Create: 961tech/src/app/api/go/r/[retailerId]/p/[listingId]/route.ts
- Create: 961tech/tests/app/api/click.test.ts
- Step 1: Write failing test
Create 961tech/tests/app/api/click.test.ts:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { db } from '@/lib/db';
describe('Click tracking endpoint', () => {
let retailerId: string;
let listingId: string;
beforeAll(async () => {
const retailer = await db.retailer.findUnique({ where: { slug: 'pcandparts' } });
if (!retailer) throw new Error('Seed first: npx prisma db seed');
retailerId = retailer.id;
const listing = await db.listing.create({
data: {
retailerId,
url: 'https://pcandparts.com/test-product',
titleRaw: 'Test CPU for click test',
},
});
listingId = listing.id;
});
afterAll(async () => {
await db.click.deleteMany({ where: { listingId } });
await db.listing.delete({ where: { id: listingId } });
});
it('creates a Click row and returns 302 to retailer URL', async () => {
const response = await fetch(
`http://localhost:3000/api/go/r/${retailerId}/p/${listingId}`,
{ redirect: 'manual' }
);
expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('https://pcandparts.com/test-product');
const click = await db.click.findFirst({
where: { listingId },
orderBy: { createdAt: 'desc' },
});
expect(click).toBeTruthy();
expect(click?.retailerId).toBe(retailerId);
expect(click?.clickToken).toMatch(/^[A-Za-z0-9_-]{12}$/);
});
it('returns 404 for unknown listing', async () => {
const response = await fetch(
`http://localhost:3000/api/go/r/${retailerId}/p/00000000-0000-0000-0000-000000000000`,
{ redirect: 'manual' }
);
expect(response.status).toBe(404);
});
});
- Step 2: Run test to verify it fails
Make sure dev server is running in another terminal:
Then in this terminal:
Expected: FAIL — endpoint doesn't exist yet (404 on the first test).
- Step 3: Implement the click endpoint
Create 961tech/src/app/api/go/r/[retailerId]/p/[listingId]/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { nanoid } from 'nanoid';
import { createHash } from 'crypto';
interface RouteParams {
params: Promise<{ retailerId: string; listingId: string }>;
}
const IP_HASH_SECRET = process.env.IP_HASH_SECRET || 'dev-secret-rotate-in-prod';
function hashIp(ip: string): string {
return createHash('sha256').update(ip + IP_HASH_SECRET).digest('hex').slice(0, 32);
}
export async function GET(req: NextRequest, { params }: RouteParams) {
const { retailerId, listingId } = await params;
const listing = await db.listing.findUnique({
where: { id: listingId },
include: { retailer: true },
});
if (!listing || listing.retailerId !== retailerId) {
return NextResponse.json({ error: 'Listing not found' }, { status: 404 });
}
const clickToken = nanoid(12);
const ip = req.headers.get('x-forwarded-for')?.split(',')[0].trim()
|| req.headers.get('x-real-ip')
|| 'unknown';
const ua = req.headers.get('user-agent') || null;
const referrer = req.headers.get('referer') || null;
const source = req.nextUrl.searchParams.get('source') || 'product_page';
await db.click.create({
data: {
clickToken,
retailerId,
listingId,
source,
ipHash: ip !== 'unknown' ? hashIp(ip) : null,
ua,
referrer,
},
});
// Append the click token as a tracking param. Retailers who later integrate S2S
// postback will be able to round-trip this token on conversion.
const target = new URL(listing.url);
target.searchParams.set('subid', clickToken);
return NextResponse.redirect(target.toString(), 302);
}
- Step 4: Run tests to verify they pass
Expected: All 2 tests PASS.
- Step 5: Commit
git add tests/app/api/click.test.ts src/app/api/go/r/[retailerId]/p/[listingId]/route.ts
git commit -m "feat: add outbound click tracking endpoint with redirect"
Task 16: Add GPU + Motherboard Scrapers (per retailer)¶
Files:
- Modify: 961tech/src/scrapers/sites/pcandparts.ts (add fetchGpuListings, fetchMotherboardListings)
- Modify: 961tech/src/scrapers/sites/souq961.ts (same)
- Modify: 961tech/src/scrapers/sites/macrotronics.ts (same)
- Create: 961tech/tests/scrapers/fixtures/pcandparts-mb-listing.html, pcandparts-gpu-listing.html, etc.
- Step 1: Add parse + fetch functions for GPU/MB to pcandparts.ts
Replace 961tech/src/scrapers/sites/pcandparts.ts:
import { load } from '@/scrapers/core/parse';
import { normalizePrice } from '@/scrapers/core/normalize';
import { fetchHtml } from '@/scrapers/core/http';
export interface ScrapedListing {
url: string;
titleRaw: string;
priceUsd: number | null;
inStock: boolean;
}
export const RETAILER_ID = 'pcandparts';
const BASE = 'https://pcandparts.com';
const CATEGORY_URLS = {
CPU: `${BASE}/product-category/computer-components/processors/`,
GPU: `${BASE}/product-category/computer-components/graphic-cards/`,
MOTHERBOARD: `${BASE}/product-category/computer-components/motherboards/`,
};
// Generic WooCommerce parser — works for all categories on PCAndParts
function parseWooCommerceListings(html: string): ScrapedListing[] {
const $ = load(html);
const listings: ScrapedListing[] = [];
$('li.product').each((_, el) => {
const $el = $(el);
const $link = $el.find('a.woocommerce-LoopProduct-link').first();
const url = $link.attr('href');
const title = $el.find('.woocommerce-loop-product__title').first().text().trim();
const priceText = $el.find('.woocommerce-Price-amount').first().text().trim();
if (!url || !title) return;
listings.push({
url,
titleRaw: title,
priceUsd: normalizePrice(priceText),
inStock: !$el.hasClass('outofstock'),
});
});
return listings;
}
export const parseCpuListings = parseWooCommerceListings;
export const parseGpuListings = parseWooCommerceListings;
export const parseMotherboardListings = parseWooCommerceListings;
export async function fetchCpuListings(): Promise<ScrapedListing[]> {
return parseCpuListings(await fetchHtml(CATEGORY_URLS.CPU));
}
export async function fetchGpuListings(): Promise<ScrapedListing[]> {
return parseGpuListings(await fetchHtml(CATEGORY_URLS.GPU));
}
export async function fetchMotherboardListings(): Promise<ScrapedListing[]> {
return parseMotherboardListings(await fetchHtml(CATEGORY_URLS.MOTHERBOARD));
}
- Step 2: Add same to souq961.ts
Replace 961tech/src/scrapers/sites/souq961.ts:
import { load } from '@/scrapers/core/parse';
import { normalizePrice } from '@/scrapers/core/normalize';
import { fetchHtml } from '@/scrapers/core/http';
import type { ScrapedListing } from './pcandparts';
export const RETAILER_ID = '961souq';
const BASE = 'https://961souq.com';
const CATEGORY_URLS = {
CPU: `${BASE}/collections/processors`,
GPU: `${BASE}/collections/graphics-cards`,
MOTHERBOARD: `${BASE}/collections/motherboards`,
};
function parseShopifyListings(html: string): ScrapedListing[] {
const $ = load(html);
const listings: ScrapedListing[] = [];
$('.product-card').each((_, el) => {
const $el = $(el);
const linkHref = $el.find('a').first().attr('href');
const title = $el.find('.card__heading').first().text().trim();
const priceText = $el.find('.price-item--regular').first().text().trim();
const isSoldOut = $el.hasClass('sold-out') || $el.find('.badge--sold-out').length > 0;
if (!linkHref || !title) return;
const url = linkHref.startsWith('http') ? linkHref : `${BASE}${linkHref}`;
listings.push({
url,
titleRaw: title,
priceUsd: normalizePrice(priceText),
inStock: !isSoldOut,
});
});
return listings;
}
export const parseCpuListings = parseShopifyListings;
export const parseGpuListings = parseShopifyListings;
export const parseMotherboardListings = parseShopifyListings;
export async function fetchCpuListings(): Promise<ScrapedListing[]> {
return parseCpuListings(await fetchHtml(CATEGORY_URLS.CPU));
}
export async function fetchGpuListings(): Promise<ScrapedListing[]> {
return parseGpuListings(await fetchHtml(CATEGORY_URLS.GPU));
}
export async function fetchMotherboardListings(): Promise<ScrapedListing[]> {
return parseMotherboardListings(await fetchHtml(CATEGORY_URLS.MOTHERBOARD));
}
- Step 3: Add same to macrotronics.ts
Replace 961tech/src/scrapers/sites/macrotronics.ts:
import { load } from '@/scrapers/core/parse';
import { normalizePrice } from '@/scrapers/core/normalize';
import { fetchHtml } from '@/scrapers/core/http';
import type { ScrapedListing } from './pcandparts';
export const RETAILER_ID = 'macrotronics';
const BASE = 'https://www.macrotronics.net';
const CATEGORY_URLS = {
CPU: `${BASE}/collections/processors`,
GPU: `${BASE}/collections/graphics-cards`,
MOTHERBOARD: `${BASE}/collections/motherboards`,
};
function parseShopifyListings(html: string): ScrapedListing[] {
const $ = load(html);
const listings: ScrapedListing[] = [];
$('.product-card, .grid__item').each((_, el) => {
const $el = $(el);
const linkHref = $el.find('a[href*="/products/"]').first().attr('href');
const title = $el.find('.card__heading, .product-card__title, h3').first().text().trim();
const priceText = $el.find('.price-item--regular, .price__current, .money').first().text().trim();
const isSoldOut = $el.hasClass('sold-out') || $el.find('.badge--sold-out, .sold-out').length > 0;
if (!linkHref || !title) return;
const url = linkHref.startsWith('http') ? linkHref : `${BASE}${linkHref}`;
listings.push({
url,
titleRaw: title,
priceUsd: normalizePrice(priceText),
inStock: !isSoldOut,
});
});
return listings;
}
export const parseCpuListings = parseShopifyListings;
export const parseGpuListings = parseShopifyListings;
export const parseMotherboardListings = parseShopifyListings;
export async function fetchCpuListings(): Promise<ScrapedListing[]> {
return parseCpuListings(await fetchHtml(CATEGORY_URLS.CPU));
}
export async function fetchGpuListings(): Promise<ScrapedListing[]> {
return parseGpuListings(await fetchHtml(CATEGORY_URLS.GPU));
}
export async function fetchMotherboardListings(): Promise<ScrapedListing[]> {
return parseMotherboardListings(await fetchHtml(CATEGORY_URLS.MOTHERBOARD));
}
- Step 4: Run all existing tests to verify nothing broke
Expected: All tests still PASS (the parsing logic is unchanged, just renamed/exported).
- Step 5: Commit
git add src/scrapers/sites/
git commit -m "feat: add GPU and motherboard scraper functions per retailer"
Task 17: Seed Canonical GPUs + Motherboards¶
Files:
- Modify: 961tech/prisma/seed.ts
- Step 1: Append GPU + Motherboard seed data
Replace 961tech/prisma/seed.ts:
import { PrismaClient, Category } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Retailers
const retailers = [
{ name: 'PC and Parts', slug: 'pcandparts', domain: 'pcandparts.com', scraperId: 'pcandparts' },
{ name: '961Souq', slug: '961souq', domain: '961souq.com', scraperId: '961souq' },
{ name: 'Macrotronics', slug: 'macrotronics', domain: 'macrotronics.net', scraperId: 'macrotronics' },
];
for (const r of retailers) {
await prisma.retailer.upsert({ where: { slug: r.slug }, create: r, update: r });
}
// CPUs (from Task 10)
const cpus = [
{ brand: 'AMD', model: 'Ryzen 9 7950X', socket: 'AM5', tdp: 170 },
{ brand: 'AMD', model: 'Ryzen 9 7950X3D', socket: 'AM5', tdp: 120 },
{ brand: 'AMD', model: 'Ryzen 9 7900X', socket: 'AM5', tdp: 170 },
{ brand: 'AMD', model: 'Ryzen 9 7900X3D', socket: 'AM5', tdp: 120 },
{ brand: 'AMD', model: 'Ryzen 7 7800X3D', socket: 'AM5', tdp: 120 },
{ brand: 'AMD', model: 'Ryzen 7 7700X', socket: 'AM5', tdp: 105 },
{ brand: 'AMD', model: 'Ryzen 7 7700', socket: 'AM5', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 7600X', socket: 'AM5', tdp: 105 },
{ brand: 'AMD', model: 'Ryzen 5 7600', socket: 'AM5', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 7500F', socket: 'AM5', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 9 9950X', socket: 'AM5', tdp: 170 },
{ brand: 'AMD', model: 'Ryzen 9 9900X', socket: 'AM5', tdp: 120 },
{ brand: 'AMD', model: 'Ryzen 7 9700X', socket: 'AM5', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 9600X', socket: 'AM5', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 7 5800X3D', socket: 'AM4', tdp: 105 },
{ brand: 'AMD', model: 'Ryzen 7 5700X', socket: 'AM4', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 5600X', socket: 'AM4', tdp: 65 },
{ brand: 'AMD', model: 'Ryzen 5 5600', socket: 'AM4', tdp: 65 },
{ brand: 'Intel', model: 'i9-14900K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i9-14900KF', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i7-14700K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i7-14700KF', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i5-14600K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i5-14400F', socket: 'LGA1700', tdp: 65 },
{ brand: 'Intel', model: 'i9-13900K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i7-13700K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'i5-13600K', socket: 'LGA1700', tdp: 125 },
{ brand: 'Intel', model: 'Core Ultra 9 285K', socket: 'LGA1851', tdp: 125 },
{ brand: 'Intel', model: 'Core Ultra 7 265K', socket: 'LGA1851', tdp: 125 },
{ brand: 'Intel', model: 'Core Ultra 5 245K', socket: 'LGA1851', tdp: 125 },
];
// Motherboards
const motherboards = [
// AM5
{ brand: 'ASUS', model: 'ROG STRIX X670E-E GAMING WIFI', socket: 'AM5', formFactor: 'ATX' },
{ brand: 'ASUS', model: 'TUF GAMING X670E-PLUS WIFI', socket: 'AM5', formFactor: 'ATX' },
{ brand: 'ASUS', model: 'PRIME X670-P', socket: 'AM5', formFactor: 'ATX' },
{ brand: 'MSI', model: 'MAG X670E TOMAHAWK WIFI', socket: 'AM5', formFactor: 'ATX' },
{ brand: 'MSI', model: 'MAG B650 TOMAHAWK WIFI', socket: 'AM5', formFactor: 'ATX' },
{ brand: 'MSI', model: 'PRO B650-P WIFI', socket: 'AM5', formFactor: 'ATX' },
{ brand: 'Gigabyte', model: 'X670 AORUS ELITE AX', socket: 'AM5', formFactor: 'ATX' },
{ brand: 'Gigabyte', model: 'B650M DS3H', socket: 'AM5', formFactor: 'mATX' },
{ brand: 'ASRock', model: 'X670E STEEL LEGEND', socket: 'AM5', formFactor: 'ATX' },
// AM4
{ brand: 'MSI', model: 'B550 TOMAHAWK', socket: 'AM4', formFactor: 'ATX' },
{ brand: 'ASUS', model: 'TUF GAMING B550-PLUS', socket: 'AM4', formFactor: 'ATX' },
{ brand: 'Gigabyte', model: 'B550 AORUS ELITE V2', socket: 'AM4', formFactor: 'ATX' },
// LGA1700
{ brand: 'ASUS', model: 'ROG STRIX Z790-E GAMING WIFI', socket: 'LGA1700', formFactor: 'ATX' },
{ brand: 'ASUS', model: 'PRIME Z790-P WIFI', socket: 'LGA1700', formFactor: 'ATX' },
{ brand: 'MSI', model: 'MAG Z790 TOMAHAWK WIFI', socket: 'LGA1700', formFactor: 'ATX' },
{ brand: 'MSI', model: 'PRO B760-P WIFI', socket: 'LGA1700', formFactor: 'ATX' },
{ brand: 'Gigabyte', model: 'Z790 AORUS ELITE AX', socket: 'LGA1700', formFactor: 'ATX' },
// LGA1851
{ brand: 'ASUS', model: 'ROG STRIX Z890-E GAMING WIFI', socket: 'LGA1851', formFactor: 'ATX' },
{ brand: 'MSI', model: 'MAG Z890 TOMAHAWK WIFI', socket: 'LGA1851', formFactor: 'ATX' },
];
// GPUs (no compat data needed for M1, just catalog)
const gpus = [
{ brand: 'NVIDIA', model: 'RTX 5090', tdp: 575, length: 304 },
{ brand: 'NVIDIA', model: 'RTX 5080', tdp: 360, length: 285 },
{ brand: 'NVIDIA', model: 'RTX 5070 Ti', tdp: 300, length: 280 },
{ brand: 'NVIDIA', model: 'RTX 5070', tdp: 250, length: 250 },
{ brand: 'NVIDIA', model: 'RTX 4090', tdp: 450, length: 336 },
{ brand: 'NVIDIA', model: 'RTX 4080 SUPER', tdp: 320, length: 304 },
{ brand: 'NVIDIA', model: 'RTX 4070 Ti SUPER', tdp: 285, length: 285 },
{ brand: 'NVIDIA', model: 'RTX 4070 SUPER', tdp: 220, length: 254 },
{ brand: 'NVIDIA', model: 'RTX 4070', tdp: 200, length: 244 },
{ brand: 'NVIDIA', model: 'RTX 4060 Ti', tdp: 165, length: 224 },
{ brand: 'NVIDIA', model: 'RTX 4060', tdp: 115, length: 200 },
{ brand: 'AMD', model: 'RX 9070 XT', tdp: 304, length: 286 },
{ brand: 'AMD', model: 'RX 9070', tdp: 220, length: 268 },
{ brand: 'AMD', model: 'RX 7900 XTX', tdp: 355, length: 287 },
{ brand: 'AMD', model: 'RX 7900 XT', tdp: 315, length: 276 },
{ brand: 'AMD', model: 'RX 7800 XT', tdp: 263, length: 267 },
{ brand: 'AMD', model: 'RX 7700 XT', tdp: 245, length: 267 },
{ brand: 'AMD', model: 'RX 7600', tdp: 165, length: 204 },
];
for (const cpu of cpus) {
const slug = `${cpu.brand}-${cpu.model}`.toLowerCase().replace(/[^a-z0-9]+/g, '-');
await prisma.product.upsert({
where: { slug },
create: {
category: Category.CPU,
brand: cpu.brand,
model: cpu.model,
slug,
cpuSocket: cpu.socket,
tdpWatts: cpu.tdp,
},
update: {},
});
}
for (const mb of motherboards) {
const slug = `${mb.brand}-${mb.model}`.toLowerCase().replace(/[^a-z0-9]+/g, '-');
await prisma.product.upsert({
where: { slug },
create: {
category: Category.MOTHERBOARD,
brand: mb.brand,
model: mb.model,
slug,
cpuSocket: mb.socket,
formFactor: mb.formFactor,
},
update: {},
});
}
for (const gpu of gpus) {
const slug = `${gpu.brand}-${gpu.model}`.toLowerCase().replace(/[^a-z0-9]+/g, '-');
await prisma.product.upsert({
where: { slug },
create: {
category: Category.GPU,
brand: gpu.brand,
model: gpu.model,
slug,
tdpWatts: gpu.tdp,
lengthMm: gpu.length,
},
update: {},
});
}
console.log(`Seeded ${retailers.length} retailers, ${cpus.length} CPUs, ${motherboards.length} MBs, ${gpus.length} GPUs`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
- Step 2: Re-run seed
Expected: Seeded 3 retailers, 30 CPUs, 19 MBs, 18 GPUs (counts may vary slightly).
- Step 3: Verify in psql
docker exec -it 961tech-postgres psql -U postgres -d tech961 -c "SELECT category, COUNT(*) FROM \"Product\" GROUP BY category;"
Expected: Three rows showing CPU, MOTHERBOARD, GPU counts.
- Step 4: Verify product list pages render
Open http://localhost:3000/products?category=GPU and http://localhost:3000/products?category=MOTHERBOARD — verify each renders with the seeded products.
Stop dev server.
- Step 5: Commit
Task 18: One-Shot Scraper Script (manual trigger for prototype)¶
Files:
- Create: 961tech/scripts/run-scrapers.ts
- Modify: 961tech/package.json (add scrape script)
For Month 1 we don't need a full BullMQ + cron setup. A one-shot script the developer runs manually is sufficient for the prototype. The full background-job runner ships in Month 2.
- Step 1: Create the manual scraper script
Create 961tech/scripts/run-scrapers.ts:
import { PrismaClient, Category } from '@prisma/client';
import * as pcandparts from '../src/scrapers/sites/pcandparts';
import * as souq961 from '../src/scrapers/sites/souq961';
import * as macrotronics from '../src/scrapers/sites/macrotronics';
import { matchListingToProduct } from '../src/lib/matching';
import type { ScrapedListing } from '../src/scrapers/sites/pcandparts';
const prisma = new PrismaClient();
interface SiteScraper {
retailerSlug: string;
fetchByCategory: Record<Category, (() => Promise<ScrapedListing[]>) | undefined>;
}
const SITES: SiteScraper[] = [
{
retailerSlug: 'pcandparts',
fetchByCategory: {
CPU: pcandparts.fetchCpuListings,
GPU: pcandparts.fetchGpuListings,
MOTHERBOARD: pcandparts.fetchMotherboardListings,
RAM: undefined, PSU: undefined, CASE: undefined, COOLER: undefined,
STORAGE: undefined, LAPTOP: undefined, PREBUILT: undefined,
},
},
{
retailerSlug: '961souq',
fetchByCategory: {
CPU: souq961.fetchCpuListings,
GPU: souq961.fetchGpuListings,
MOTHERBOARD: souq961.fetchMotherboardListings,
RAM: undefined, PSU: undefined, CASE: undefined, COOLER: undefined,
STORAGE: undefined, LAPTOP: undefined, PREBUILT: undefined,
},
},
{
retailerSlug: 'macrotronics',
fetchByCategory: {
CPU: macrotronics.fetchCpuListings,
GPU: macrotronics.fetchGpuListings,
MOTHERBOARD: macrotronics.fetchMotherboardListings,
RAM: undefined, PSU: undefined, CASE: undefined, COOLER: undefined,
STORAGE: undefined, LAPTOP: undefined, PREBUILT: undefined,
},
},
];
const ACTIVE_CATEGORIES: Category[] = ['CPU', 'GPU', 'MOTHERBOARD'];
async function ingest(site: SiteScraper, category: Category, scraped: ScrapedListing[]) {
const retailer = await prisma.retailer.findUnique({ where: { slug: site.retailerSlug } });
if (!retailer) {
console.warn(`Retailer not found: ${site.retailerSlug}`);
return;
}
const products = await prisma.product.findMany({
where: { category },
select: { id: true, brand: true, model: true },
});
let inserted = 0;
let updated = 0;
let matched = 0;
for (const item of scraped) {
const match = matchListingToProduct(item.titleRaw, products);
const listing = await prisma.listing.upsert({
where: { retailerId_url: { retailerId: retailer.id, url: item.url } },
create: {
retailerId: retailer.id,
url: item.url,
titleRaw: item.titleRaw,
productId: match.productId,
matchConfidence: match.confidence,
matchStatus: match.productId ? 'auto' : 'unmatched',
lastSeenAt: new Date(),
},
update: {
titleRaw: item.titleRaw,
productId: match.productId ?? undefined,
matchConfidence: match.confidence,
matchStatus: match.productId ? 'auto' : 'unmatched',
lastSeenAt: new Date(),
},
});
await prisma.listingPrice.create({
data: {
listingId: listing.id,
priceUsd: item.priceUsd ?? null,
inStock: item.inStock,
},
});
if (match.productId) matched++;
if (listing.createdAt.getTime() === listing.updatedAt.getTime()) inserted++;
else updated++;
}
console.log(
` ${category}: ${scraped.length} scraped → ${inserted} new + ${updated} updated, ${matched} matched`
);
}
async function main() {
for (const site of SITES) {
console.log(`\nScraping ${site.retailerSlug}…`);
for (const category of ACTIVE_CATEGORIES) {
const fetcher = site.fetchByCategory[category];
if (!fetcher) continue;
try {
const scraped = await fetcher();
await ingest(site, category, scraped);
} catch (err) {
console.error(` ${category}: FAILED — ${(err as Error).message}`);
}
}
}
console.log('\nDone.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
- Step 2: Add scrape script to package.json
In 961tech/package.json "scripts" block, add:
- Step 3: Run the scrapers
Expected output (counts will vary):
Scraping pcandparts…
CPU: 24 scraped → 24 new + 0 updated, 18 matched
GPU: 18 scraped → 18 new + 0 updated, 14 matched
MOTHERBOARD: 32 scraped → 32 new + 0 updated, 11 matched
Scraping 961souq…
CPU: ...
...
Done.
If some retailers fail (network, layout drift), the script continues with the others. Failures are not fatal in this prototype.
- Step 4: Verify product pages now show prices
Open http://localhost:3000/products?category=CPU — products that got matched should now show prices (e.g., "from $549.00" + "3 retailers").
Open one product detail page — the PriceTable should populate.
Stop dev server.
- Step 5: Commit
git add scripts/run-scrapers.ts package.json
git commit -m "feat: add manual scraper runner for prototype"
Task 19: Build Wizard Step 1 — CPU Selection¶
Files:
- Create: 961tech/src/app/build/page.tsx
- Create: 961tech/src/components/CpuPicker.tsx
- Step 1: Create CpuPicker client component
Create 961tech/src/components/CpuPicker.tsx:
'use client';
import Link from 'next/link';
import { useState } from 'react';
interface CpuOption {
id: string;
slug: string;
brand: string;
model: string;
socket: string | null;
cheapestPriceUsd: number | null;
retailerCount: number;
}
interface CpuPickerProps {
cpus: CpuOption[];
}
export function CpuPicker({ cpus }: CpuPickerProps) {
const [query, setQuery] = useState('');
const [socketFilter, setSocketFilter] = useState<string | null>(null);
const sockets = [...new Set(cpus.map((c) => c.socket).filter(Boolean) as string[])].sort();
const filtered = cpus.filter((c) => {
if (socketFilter && c.socket !== socketFilter) return false;
if (query && !`${c.brand} ${c.model}`.toLowerCase().includes(query.toLowerCase())) return false;
return true;
});
return (
<div className="space-y-4">
<div className="flex gap-3 flex-wrap items-center">
<input
type="search"
placeholder="Search CPUs..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1 min-w-[200px] rounded-md bg-zinc-900 border border-zinc-800 px-3 py-2 text-sm focus:border-cyan-500 focus:outline-none"
/>
<div className="flex gap-1">
<button
onClick={() => setSocketFilter(null)}
className={`px-3 py-2 rounded-md text-sm font-medium transition ${
socketFilter === null ? 'bg-cyan-500 text-zinc-950' : 'bg-zinc-900 hover:bg-zinc-800'
}`}
>
All
</button>
{sockets.map((s) => (
<button
key={s}
onClick={() => setSocketFilter(s)}
className={`px-3 py-2 rounded-md text-sm font-mono transition ${
socketFilter === s ? 'bg-cyan-500 text-zinc-950' : 'bg-zinc-900 hover:bg-zinc-800'
}`}
>
{s}
</button>
))}
</div>
</div>
<p className="text-xs text-zinc-500">{filtered.length} CPUs</p>
<div className="space-y-2">
{filtered.map((cpu) => (
<Link
key={cpu.id}
href={`/build/motherboard?cpu=${cpu.slug}`}
className="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900/30 p-3 hover:border-cyan-500/50 transition"
>
<div>
<div className="text-xs text-zinc-500 uppercase">{cpu.brand}</div>
<div className="font-medium">{cpu.model}</div>
</div>
<div className="flex items-center gap-4">
{cpu.socket && <span className="text-xs font-mono text-cyan-400">{cpu.socket}</span>}
<div className="text-right">
{cpu.cheapestPriceUsd !== null ? (
<>
<div className="text-xs text-zinc-500">from</div>
<div className="font-bold text-cyan-400">${cpu.cheapestPriceUsd.toFixed(2)}</div>
</>
) : (
<span className="text-xs text-zinc-600">Not in stock</span>
)}
</div>
</div>
</Link>
))}
</div>
</div>
);
}
- Step 2: Create the build wizard page (server component)
Create 961tech/src/app/build/page.tsx:
import { db } from '@/lib/db';
import { CpuPicker } from '@/components/CpuPicker';
export const dynamic = 'force-dynamic';
export default async function BuildStartPage() {
const cpus = await db.product.findMany({
where: { category: 'CPU' },
include: {
listings: {
where: { matchStatus: { in: ['auto', 'manual'] } },
include: { prices: { orderBy: { scrapedAt: 'desc' }, take: 1 } },
},
},
orderBy: [{ brand: 'asc' }, { model: 'asc' }],
});
const options = cpus.map((c) => {
const validPrices = c.listings
.map((l) => l.prices[0]?.priceUsd)
.filter((x): x is NonNullable<typeof x> => x !== undefined && x !== null)
.map((d) => Number(d));
return {
id: c.id,
slug: c.slug,
brand: c.brand,
model: c.model,
socket: c.cpuSocket,
cheapestPriceUsd: validPrices.length > 0 ? Math.min(...validPrices) : null,
retailerCount: new Set(c.listings.map((l) => l.retailerId)).size,
};
});
return (
<div className="space-y-8">
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider">Step 1 of 2</div>
<h1 className="text-3xl font-bold mt-1">Choose a CPU</h1>
<p className="text-zinc-400 mt-2">
Pick your processor first. We'll filter compatible motherboards next.
</p>
</div>
<CpuPicker cpus={options} />
</div>
);
}
- Step 3: Run dev server and verify
Open http://localhost:3000/build. Verify:
- "Step 1 of 2" + "Choose a CPU" header
- Search input works
- Socket filter buttons (AM5, AM4, LGA1700, LGA1851) work
- CPUs list with prices (where matched) and "Not in stock" otherwise
Stop dev server.
- Step 4: Commit
git add src/app/build/page.tsx src/components/CpuPicker.tsx
git commit -m "feat: add build wizard step 1 (CPU selection)"
Task 20: Build Wizard Step 2 — Motherboard with Compatibility Filter¶
Files:
- Create: 961tech/src/app/build/motherboard/page.tsx
- Create: 961tech/src/components/MotherboardPicker.tsx
- Step 1: Create MotherboardPicker client component
Create 961tech/src/components/MotherboardPicker.tsx:
'use client';
import { useState } from 'react';
import { cpuFitsMotherboard } from '@/rules/cpuMotherboard';
interface MbOption {
id: string;
slug: string;
brand: string;
model: string;
socket: string | null;
formFactor: string | null;
cheapestPriceUsd: number | null;
}
interface CpuSummary {
brand: string;
model: string;
cpuSocket: string | null;
}
interface MotherboardPickerProps {
cpu: CpuSummary;
motherboards: MbOption[];
buildShareUrl: string;
}
export function MotherboardPicker({ cpu, motherboards, buildShareUrl }: MotherboardPickerProps) {
const [hideIncompatible, setHideIncompatible] = useState(true);
const [selectedMb, setSelectedMb] = useState<MbOption | null>(null);
const annotated = motherboards
.map((mb) => ({
...mb,
compat: cpuFitsMotherboard(cpu, { brand: mb.brand, model: mb.model, cpuSocket: mb.socket }),
}))
.filter((mb) => !hideIncompatible || mb.compat.ok);
// Sort: compatible first, then by price
annotated.sort((a, b) => {
if (a.compat.ok !== b.compat.ok) return a.compat.ok ? -1 : 1;
if (a.cheapestPriceUsd === null) return 1;
if (b.cheapestPriceUsd === null) return -1;
return a.cheapestPriceUsd - b.cheapestPriceUsd;
});
const totalPrice =
selectedMb && selectedMb.cheapestPriceUsd !== null
? selectedMb.cheapestPriceUsd
: null;
return (
<div className="space-y-6">
<div className="rounded-md bg-cyan-950/30 border border-cyan-900 p-3 text-sm">
Your CPU: <span className="font-mono">{cpu.brand} {cpu.model}</span>
{cpu.cpuSocket && <span className="ml-2 text-cyan-400">(Socket {cpu.cpuSocket})</span>}
</div>
<label className="flex items-center gap-2 text-sm text-zinc-400 cursor-pointer">
<input
type="checkbox"
checked={hideIncompatible}
onChange={(e) => setHideIncompatible(e.target.checked)}
className="rounded"
/>
Hide incompatible motherboards
</label>
<p className="text-xs text-zinc-500">{annotated.length} motherboards</p>
<div className="space-y-2">
{annotated.map((mb) => (
<button
key={mb.id}
onClick={() => setSelectedMb(mb)}
disabled={!mb.compat.ok}
className={`w-full flex items-center justify-between rounded-md border p-3 text-left transition ${
selectedMb?.id === mb.id
? 'border-cyan-500 bg-cyan-950/30'
: mb.compat.ok
? 'border-zinc-800 bg-zinc-900/30 hover:border-cyan-500/50'
: 'border-zinc-900 bg-zinc-950 opacity-50 cursor-not-allowed'
}`}
>
<div>
<div className="text-xs text-zinc-500 uppercase">{mb.brand}</div>
<div className="font-medium">{mb.model}</div>
{!mb.compat.ok && (
<div className="text-xs text-red-400 mt-1">{mb.compat.reason}</div>
)}
</div>
<div className="flex items-center gap-4">
{mb.socket && <span className="text-xs font-mono text-cyan-400">{mb.socket}</span>}
{mb.formFactor && <span className="text-xs text-zinc-500">{mb.formFactor}</span>}
<div className="text-right">
{mb.cheapestPriceUsd !== null ? (
<div className="font-bold text-cyan-400">${mb.cheapestPriceUsd.toFixed(2)}</div>
) : (
<span className="text-xs text-zinc-600">Not in stock</span>
)}
</div>
</div>
</button>
))}
</div>
{selectedMb && (
<div className="sticky bottom-4 rounded-lg border border-cyan-500 bg-zinc-950 p-4 shadow-lg shadow-cyan-500/20">
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-zinc-500 uppercase">Build summary</div>
<div className="text-sm mt-1">
{cpu.brand} {cpu.model} + {selectedMb.brand} {selectedMb.model}
</div>
{totalPrice !== null && (
<div className="text-2xl font-bold text-cyan-400 mt-1">
${totalPrice.toFixed(2)}
</div>
)}
</div>
<a
href={`${buildShareUrl}&mb=${selectedMb.slug}`}
className="bg-cyan-500 hover:bg-cyan-400 text-zinc-950 px-4 py-2 rounded-md font-semibold transition"
>
View build →
</a>
</div>
</div>
)}
</div>
);
}
- Step 2: Create the motherboard step page
Create 961tech/src/app/build/motherboard/page.tsx:
import { db } from '@/lib/db';
import { MotherboardPicker } from '@/components/MotherboardPicker';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ cpu?: string }>;
}
export default async function MotherboardStepPage({ searchParams }: PageProps) {
const { cpu: cpuSlug } = await searchParams;
if (!cpuSlug) redirect('/build');
const cpu = await db.product.findUnique({ where: { slug: cpuSlug } });
if (!cpu || cpu.category !== 'CPU') redirect('/build');
const mbs = await db.product.findMany({
where: { category: 'MOTHERBOARD' },
include: {
listings: {
where: { matchStatus: { in: ['auto', 'manual'] } },
include: { prices: { orderBy: { scrapedAt: 'desc' }, take: 1 } },
},
},
orderBy: [{ brand: 'asc' }, { model: 'asc' }],
});
const options = mbs.map((mb) => {
const validPrices = mb.listings
.map((l) => l.prices[0]?.priceUsd)
.filter((x): x is NonNullable<typeof x> => x !== undefined && x !== null)
.map((d) => Number(d));
return {
id: mb.id,
slug: mb.slug,
brand: mb.brand,
model: mb.model,
socket: mb.cpuSocket,
formFactor: mb.formFactor,
cheapestPriceUsd: validPrices.length > 0 ? Math.min(...validPrices) : null,
};
});
return (
<div className="space-y-8">
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider">Step 2 of 2</div>
<h1 className="text-3xl font-bold mt-1">Choose a motherboard</h1>
<p className="text-zinc-400 mt-2">
We've filtered to motherboards compatible with your CPU's socket.
</p>
</div>
<MotherboardPicker
cpu={{ brand: cpu.brand, model: cpu.model, cpuSocket: cpu.cpuSocket }}
motherboards={options}
buildShareUrl={`/build/summary?cpu=${cpu.slug}`}
/>
</div>
);
}
- Step 3: Run dev server and verify
Test the flow:
1. Open http://localhost:3000/build
2. Pick "AMD Ryzen 7 7800X3D"
3. Verify the motherboard page shows AM5 motherboards as compatible (not greyed out)
4. Toggle "Hide incompatible" off — verify LGA1700 + LGA1851 mobos appear with red incompatibility messages
5. Click an AM5 mobo — sticky build summary appears at bottom
6. Click "View build →" — navigates to /build/summary?cpu=...&mb=... (404 — that's Task 21)
Stop dev server.
- Step 4: Commit
git add src/app/build/motherboard/page.tsx src/components/MotherboardPicker.tsx
git commit -m "feat: add build wizard step 2 (motherboard with compat filter)"
Task 21: Build Summary Page + Share URL¶
Files:
- Create: 961tech/src/app/build/summary/page.tsx
- Step 1: Create the summary page
Create 961tech/src/app/build/summary/page.tsx:
import { db } from '@/lib/db';
import { redirect } from 'next/navigation';
import Link from 'next/link';
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ cpu?: string; mb?: string }>;
}
export default async function BuildSummaryPage({ searchParams }: PageProps) {
const { cpu: cpuSlug, mb: mbSlug } = await searchParams;
if (!cpuSlug || !mbSlug) redirect('/build');
const [cpu, mb] = await Promise.all([
db.product.findUnique({
where: { slug: cpuSlug },
include: {
listings: {
where: { matchStatus: { in: ['auto', 'manual'] } },
include: {
retailer: true,
prices: { orderBy: { scrapedAt: 'desc' }, take: 1 },
},
},
},
}),
db.product.findUnique({
where: { slug: mbSlug },
include: {
listings: {
where: { matchStatus: { in: ['auto', 'manual'] } },
include: {
retailer: true,
prices: { orderBy: { scrapedAt: 'desc' }, take: 1 },
},
},
},
}),
]);
if (!cpu || !mb) redirect('/build');
const cheapestListing = (product: typeof cpu) => {
const inStock = product.listings.filter((l) => l.prices[0]?.inStock);
if (inStock.length === 0) return null;
return inStock.reduce((best, l) => {
const price = l.prices[0]?.priceUsd ? Number(l.prices[0].priceUsd) : Infinity;
const bestPrice = best.prices[0]?.priceUsd ? Number(best.prices[0].priceUsd) : Infinity;
return price < bestPrice ? l : best;
});
};
const cpuListing = cheapestListing(cpu);
const mbListing = cheapestListing(mb);
const total =
(cpuListing?.prices[0]?.priceUsd ? Number(cpuListing.prices[0].priceUsd) : 0) +
(mbListing?.prices[0]?.priceUsd ? Number(mbListing.prices[0].priceUsd) : 0);
return (
<div className="space-y-8 max-w-3xl mx-auto">
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider">Your build</div>
<h1 className="text-3xl font-bold mt-1">Ready to assemble</h1>
<p className="text-zinc-400 mt-2">
Cheapest split across Lebanese retailers. Click each part to buy from that store.
</p>
</div>
<div className="space-y-3">
<BuildPart label="CPU" product={cpu} cheapest={cpuListing} />
<BuildPart label="Motherboard" product={mb} cheapest={mbListing} />
</div>
<div className="rounded-lg border border-cyan-500 bg-cyan-950/20 p-6">
<div className="flex items-baseline justify-between">
<span className="text-zinc-400">Total estimated cost</span>
<span className="text-3xl font-bold text-cyan-400">${total.toFixed(2)}</span>
</div>
{(cpuListing && mbListing && cpuListing.retailer.id !== mbListing.retailer.id) && (
<p className="text-xs text-zinc-500 mt-2">
⚠ Buying from 2 stores — you'll check out separately at each retailer.
</p>
)}
</div>
<div className="flex gap-3 justify-between">
<Link href="/build" className="text-sm text-zinc-400 hover:text-cyan-400">
← Start over
</Link>
<ShareButton cpuSlug={cpu.slug} mbSlug={mb.slug} />
</div>
</div>
);
}
function BuildPart({
label,
product,
cheapest,
}: {
label: string;
product: { brand: string; model: string; slug: string };
cheapest: {
id: string;
retailerId: string;
retailer: { name: string };
prices: { priceUsd: unknown }[];
} | null;
}) {
const price =
cheapest?.prices[0]?.priceUsd !== undefined && cheapest?.prices[0]?.priceUsd !== null
? Number(cheapest.prices[0].priceUsd)
: null;
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4">
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-zinc-500 uppercase">{label}</div>
<Link href={`/products/${product.slug}`} className="font-medium hover:text-cyan-400">
{product.brand} {product.model}
</Link>
{cheapest && (
<div className="text-xs text-zinc-500 mt-1">
Cheapest at <span className="text-cyan-400">{cheapest.retailer.name}</span>
</div>
)}
</div>
<div className="text-right">
{price !== null && cheapest ? (
<>
<div className="font-bold text-cyan-400 text-lg">${price.toFixed(2)}</div>
<a
href={`/api/go/r/${cheapest.retailerId}/p/${cheapest.id}?source=build_summary`}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-1 bg-cyan-500 hover:bg-cyan-400 text-zinc-950 px-3 py-1 rounded text-xs font-semibold transition"
>
Buy →
</a>
</>
) : (
<span className="text-sm text-zinc-500">No stock</span>
)}
</div>
</div>
</div>
);
}
function ShareButton({ cpuSlug, mbSlug }: { cpuSlug: string; mbSlug: string }) {
return (
<a
href={`/build/summary?cpu=${cpuSlug}&mb=${mbSlug}`}
className="text-sm bg-zinc-800 hover:bg-zinc-700 px-4 py-2 rounded-md font-medium"
>
Copy share link
</a>
);
}
- Step 2: Run dev server and walk the full flow
End-to-end manual test:
1. Open http://localhost:3000
2. Click "Build a PC"
3. Select an AM5 CPU (any with prices)
4. Select an AM5 motherboard (any with prices)
5. Click "View build →"
6. Verify summary page shows: CPU + MB, cheapest retailer name, total price, "Buy →" buttons
7. Click "Buy →" on CPU — should 302-redirect to the retailer URL with subid= token appended
8. Verify a Click row was created:
docker exec -it 961tech-postgres psql -U postgres -d tech961 -c "SELECT \"clickToken\", source, \"createdAt\" FROM \"Click\" ORDER BY \"createdAt\" DESC LIMIT 5;"
Stop dev server.
- Step 3: Commit
git add src/app/build/summary/page.tsx
git commit -m "feat: add build summary page with cheapest-retailer split"
Task 22: Mobile Responsive QA Pass¶
Files:
- Modify: 961tech/src/app/layout.tsx (responsive nav)
- Modify: any pages with overflow issues
- Step 1: Test the site at mobile viewport
Run dev server:
In Chrome DevTools, switch to a mobile viewport (e.g., iPhone 14 Pro at 393×852). Walk through: 1. Landing page — buttons should stack on narrow screens 2. Products page — grid should collapse to 1 column 3. Product detail page — PriceTable should remain readable (may need horizontal scroll) 4. Build wizard step 1 — search + filters should wrap nicely 5. Build wizard step 2 — sticky bottom panel should fit 6. Build summary — total + buy buttons readable
Identify any layout issues.
- Step 2: Fix PriceTable horizontal overflow on mobile
Modify 961tech/src/components/PriceTable.tsx — wrap the <table> in a horizontal-scroll container.
Find:
return (
<div className="rounded-lg border border-zinc-800 overflow-hidden">
<table className="w-full">
Replace with:
return (
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
<table className="w-full min-w-[600px]">
- Step 3: Add responsive nav links spacing
In 961tech/src/app/layout.tsx, the nav link container is <div className="flex gap-6 text-sm">. Change gap-6 to gap-3 sm:gap-6 for tighter spacing on mobile:
- Step 4: Re-test mobile viewport
Re-run the manual flow at mobile width. Confirm: - Landing buttons fit - Product grid collapses to 1 column - Price table scrolls horizontally cleanly (no clipping) - Wizard sticky bottom panel doesn't overflow
Stop dev server.
- Step 5: Commit
git add src/components/PriceTable.tsx src/app/layout.tsx
git commit -m "fix: mobile responsive polish on PriceTable and nav"
Task 23: README + Setup Documentation¶
Files:
- Modify: 961tech/README.md
- Step 1: Replace README.md with complete setup docs
Replace 961tech/README.md:
# 961tech — Lebanese Tech Aggregator
Compatibility-checked PC builder + multi-retailer price comparison for Lebanon.
**Status:** Month 1 prototype (localhost only). 3 retailers (PCAndParts, 961Souq, Macrotronics), 3 categories (CPU/GPU/MB), basic CPU↔MB compatibility check, click tracking. No auth, no vendor backoffice, no S2S postback in this prototype — those ship in M2/M3.
## Prerequisites
- Node.js 20+
- Docker Desktop (or Docker Engine + Compose)
- npm 10+
## First-time setup
```bash
# 1. Install dependencies
npm install
# 2. Start Postgres + Redis
docker compose up -d
# 3. Wait ~10 seconds, then check both services are healthy
docker compose ps
# 4. Run database migration
npx prisma migrate dev
# 5. Seed retailers + canonical product catalog (~30 CPUs, ~19 MBs, ~18 GPUs)
npx prisma db seed
# 6. Run scrapers once to populate listings + prices (takes 30-60s)
npm run scrape
# 7. Start the dev server
npm run dev
```
Open http://localhost:3000.
## Daily development
```bash
# Start everything (if Docker isn't already running)
docker compose up -d
# Dev server with hot reload
npm run dev
# Run scrapers manually whenever you want fresh prices
npm run scrape
# Run all tests
npm test
# Watch mode for tests
npm run test:watch
```
## Project structure
- `prisma/` — schema, migrations, seed
- `src/app/` — Next.js App Router pages and API routes
- `src/components/` — React components
- `src/lib/` — Prisma client, fuzzy matching
- `src/rules/` — Compatibility rules engine
- `src/scrapers/` — Per-retailer scrapers + core HTTP/parse helpers
- `scripts/run-scrapers.ts` — One-shot scraper runner (manual trigger)
- `tests/` — Vitest unit + integration tests
## Adding a new retailer
1. Create `src/scrapers/sites/<retailer>.ts` exporting `parseCpuListings`, `parseGpuListings`, `parseMotherboardListings`, and the matching `fetch*` functions
2. Add a fixture HTML to `tests/scrapers/fixtures/`
3. Add a test in `tests/scrapers/<retailer>.test.ts`
4. Add a row to `prisma/seed.ts` retailers array
5. Re-seed: `npx prisma db seed`
6. Add to `SITES` array in `scripts/run-scrapers.ts`
7. Run `npm run scrape`
## Adding a new compatibility rule
1. Add the rule function in `src/rules/<rule-name>.ts`, returning `RuleResult`
2. Write Vitest tests in `tests/rules/<rule-name>.test.ts` first (TDD)
3. Export from `src/rules/index.ts`
4. Wire into the relevant wizard step
## Reset database
```bash
npx prisma migrate reset
# Type 'y' to confirm. Re-runs migrations and seed automatically.
npm run scrape
```
## Troubleshooting
**Postgres won't start:** `docker compose down -v` (drops the volume), then `docker compose up -d`. Re-run migrations + seed.
**Scrapers return 0 results:** the retailer site likely changed layout. Check the fixture HTML in `tests/scrapers/fixtures/` and the parser in `src/scrapers/sites/`. Update both.
**Click endpoint returns 404:** the listing ID doesn't exist. Re-run `npm run scrape` to populate listings.
**Dev server can't connect to DB:** confirm `.env` has `DATABASE_URL="postgresql://postgres:postgres@localhost:5433/tech961?schema=public"` and Docker is running.
## License
Private. Not for redistribution.
- Step 2: Final test pass
Run all tests:
Expected: All tests PASS across rules, scrapers, lib, and api directories.
- Step 3: Final manual end-to-end smoke test
Walk: 1. Landing → Build → Step 1 → Step 2 → Summary → Click "Buy" 2. Browse → Category filter → Product detail → Click "Buy" from price table
Verify all clicks land on retailer URLs and produce Click rows in DB.
Stop dev server.
- Step 4: Final commit
git add README.md
git commit -m "docs: add complete README with setup, daily dev, and troubleshooting"
Task 24: Demo Snapshot + Wrap-up¶
Files:
- Create: 961tech/MONTH-1-DEMO.md
- Step 1: Document what the prototype does for the demo
Create 961tech/MONTH-1-DEMO.md:
# Month 1 Prototype — Demo Script
This is the script for showing the prototype to a friend or potential pilot retailer.
## Setup (one-time)
Follow `README.md` first-time setup. Confirm `npm run scrape` has run and the DB has listings.
## Demo flow (~5 minutes)
### 1. Landing page
Open http://localhost:3000
**Show:** Clean, dark, branded landing. "Lebanese tech, finally compared." Two CTAs.
### 2. Browse mode
Click "Browse parts"
**Show:**
- Category tabs (CPU / GPU / Motherboard)
- Product cards with brand, model, price (where matched), retailer count
- Switch categories — instant filter
### 3. Product detail
Click any product card with a price
**Show:**
- Product header with socket + TDP tags
- "Available at" table with all retailers carrying this product
- Cheapest highlighted
- "Buy" button per retailer
- Price comparison across stores — the core value prop
### 4. Click tracking demonstration
Click "Buy" on a retailer
**Show:**
- 302 redirect to the actual retailer site (e.g., pcandparts.com/...)
- Notice the URL has `?subid=...` appended — that's the tracking token
- Back in psql: `SELECT * FROM "Click" ORDER BY "createdAt" DESC LIMIT 1;` shows the row was created
### 5. Build wizard
Open http://localhost:3000/build
**Show:**
- Step 1: search + socket filter — "I want an AM5 CPU"
- Pick "AMD Ryzen 7 7800X3D"
- Step 2: motherboards filter — only AM5 boards shown by default
- Toggle "Hide incompatible" off → LGA1700/LGA1851 boards greyed with red message
- Pick a compatible motherboard
- Sticky build summary at bottom
- Click "View build →"
### 6. Build summary
**Show:**
- CPU + MB with cheapest retailer per part
- Total price calculation
- Multi-store warning if applicable
- "Buy" buttons go through tracked clicks
## What's NOT in M1 (set expectations for the viewer)
- No login / saved builds (M2)
- No vendor backoffice (M2)
- No real-time auto-scraping — manual `npm run scrape` (M2 adds BullMQ scheduled jobs)
- Only 3 retailers, 3 categories (M2 adds laptops, prebuilt, more retailers)
- No automated commission tracking (M3 adds S2S postbacks + ledger)
- No production deployment — localhost only (M2 deploys to bits)
## Feedback questions to ask
1. Is the build wizard step intuitive? Where did you get stuck?
2. Would you trust the price comparison? What would make you trust it more?
3. If you were a retailer, would you sign up to be tracked? What would you want in return?
4. What's the first feature you'd want added?
- Step 2: Final commit
- Step 3: Confirm git log shows the build progression
Expected: ~24 commits showing the full progression from scaffold through demo doc.
- Step 4: Tag the prototype
- Step 5: Celebrate
You've shipped the Month 1 prototype. Time to demo it to a friend, get feedback, and start the M2 plan.
Plan complete¶
This plan implements the Month 1 Localhost Prototype scope from the design spec at Specs → 2026-04-25 aggregator design.
What's built: - Next.js 15 + Postgres + Redis localhost stack - 3 retailer scrapers (PCAndParts, 961Souq, Macrotronics) for CPU/GPU/MB - Canonical product catalog (30 CPUs, 19 MBs, 18 GPUs) - Fuzzy listing-to-product matching - Public product list + detail pages with price comparison - Outbound click tracking endpoint with token + IP hash - 2-step compatibility-checked build wizard (CPU → MB) - Build summary with cheapest-retailer split - Mobile responsive - 24 commits, ~17 hours of focused dev time, all tests passing
What's deferred to M2 (next plan):
- Auth.js setup + saved builds
- Vendor backoffice (product CRUD, CSV import, analytics dashboard)
- BullMQ scheduled scrapers (replace manual npm run scrape)
- LLM-extraction pipeline for spec data
- Multi-merchant cart with optimizer
- Deploy to bits beta
- More retailers (target 6–8 total)
- More compatibility rules (PSU wattage, case GPU clearance, cooler height, RAM type)
- Add laptops + prebuilt categories (browse mode, no compat logic)
Spec coverage check (this plan vs M1 milestones in spec §8.6): - ✅ "Localhost prototype demos to a friend" - ✅ "They can browse 3 retailers, build a CPU+MB combo, click out"
The Month 1 milestone is fully covered.