Skip to content

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
mkdir -p /c/Users/USER/Desktop/Claude/961tech
cd /c/Users/USER/Desktop/Claude/961tech
git init

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
npx prisma init --datasource-provider postgresql

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
git add .
git commit -m "chore: scaffold Next.js 15 + TypeScript + Tailwind + Prisma"

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
docker compose up -d
docker compose ps

Expected: Both 961tech-postgres and 961tech-redis show status healthy (wait ~10s if "starting").

  • Step 5: Verify Postgres connection
docker exec -it 961tech-postgres psql -U postgres -d tech961 -c "SELECT version();"

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
npx prisma migrate dev --name init

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
npx prisma generate

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
docker exec -it 961tech-postgres psql -U postgres -d tech961 -c "\dt"

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:

"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
  • 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:

export * from './types';
export * from './cpuMotherboard';

(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
npx vitest --version

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
npx vitest run tests/rules/cpuMotherboard.test.ts

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
npx vitest run tests/rules/cpuMotherboard.test.ts

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
npx vitest run tests/scrapers/core/normalize.test.ts

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
npx vitest run tests/scrapers/core/normalize.test.ts

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
npx vitest run tests/scrapers/pcandparts.test.ts

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
npx vitest run tests/scrapers/pcandparts.test.ts

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
npx vitest run tests/scrapers/souq961.test.ts

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
npx vitest run tests/scrapers/souq961.test.ts

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
npx vitest run tests/scrapers/macrotronics.test.ts

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
npx vitest run tests/scrapers/macrotronics.test.ts

Expected: All 3 tests PASS.

  • Step 6: Run all tests to verify nothing else broke
npx vitest run

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"):

"prisma": {
  "seed": "tsx prisma/seed.ts"
}
  • Step 3: Run the seed
npx prisma db 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
git add prisma/seed.ts package.json
git commit -m "feat: seed 3 retailers + canonical CPU catalog"

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
npx vitest run tests/lib/matching.test.ts

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
npx vitest run tests/lib/matching.test.ts

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
npm run dev

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
git add src/app/layout.tsx src/app/page.tsx
git commit -m "feat: add root layout and landing page"

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
npm run dev

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
npm run dev

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:

npm run dev

Then in this terminal:

npx vitest run tests/app/api/click.test.ts

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
npx vitest run tests/app/api/click.test.ts

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
npx vitest run

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
npx prisma db 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
npm run dev

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
git add prisma/seed.ts
git commit -m "feat: seed canonical motherboard and GPU catalogs"

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:

"scrape": "tsx scripts/run-scrapers.ts"
  • Step 3: Run the scrapers
npm run scrape

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
npm run dev

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
npm run dev

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
npm run dev

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
npm run dev

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:

npm run dev

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:

<div className="flex gap-3 sm:gap-6 text-sm">
  • 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:

npm test

Expected: All tests PASS across rules, scrapers, lib, and api directories.

  • Step 3: Final manual end-to-end smoke test
npm run dev

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
git add MONTH-1-DEMO.md
git commit -m "docs: add Month 1 demo script"
  • Step 3: Confirm git log shows the build progression
git log --oneline

Expected: ~24 commits showing the full progression from scaffold through demo doc.

  • Step 4: Tag the prototype
git tag -a v0.1.0-prototype -m "Month 1 localhost prototype complete"
  • 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.