Skip to content

Architecture overview

This is the system-level narrative. For more granular views, see the other architecture pages — data model, lifecycles, sequences, components, deployment, packages.


Solution Architecture

graph TB
    subgraph Browser["Browser (Lebanese user, mobile + desktop)"]
        UI[Next.js App<br/>RSC + Client Islands]
    end

    subgraph Edge["Cloudflare Edge"]
        CDN[CDN + DDoS + TLS]
    end

    subgraph App["Application Tier (Vercel/bits)"]
        Pages[Next.js App Router<br/>RSC pages]
        API[Route Handlers<br/>/api/go, /api/build, /api/health]
        ServerActions[Server Actions<br/>save build, share]
    end

    subgraph Data["Data Tier"]
        DB[(Postgres 17<br/>Neon/Hetzner)]
        Cache[(Redis<br/>BullMQ + ratelimit)]
        Files[Cloudflare R2<br/>product images]
    end

    subgraph Workers["Background Workers (Hetzner VPS)"]
        Scraper[Scraper Service<br/>Node + Cheerio + undici]
        Matcher[Matching Pipeline<br/>fuzzy + LLM-fallback]
        ImageBot[Image Fetcher<br/>downloads + R2 upload]
        Health[Health Monitor<br/>scraper drift + Telegram alerts]
    end

    subgraph Retailers["Lebanese Retailers"]
        PCAP[pcandparts.com<br/>WooCommerce/Flatsome]
        SOUQ[961souq.com<br/>Shopify custom]
        MACRO[macrotronics.net<br/>Shopify]
        FUTURE[+ 5-15 more in M2/M3]
    end

    subgraph LLM["External Services"]
        Anthropic[Anthropic API<br/>Claude Haiku<br/>spec extraction + AIB matching]
    end

    UI --> CDN
    CDN --> Pages
    CDN --> API
    Pages --> DB
    API --> DB
    API --> Cache
    ServerActions --> DB
    Pages --> Files

    Scraper --> Retailers
    Scraper --> DB
    Scraper --> Cache
    Matcher --> DB
    Matcher --> Anthropic
    ImageBot --> Retailers
    ImageBot --> Files
    Health --> Cache
    Health --> Scraper

    style UI fill:#5d8b3e,stroke:#0a0e14,color:#f0e6d2
    style Pages fill:#0a0e14,stroke:#5d8b3e,color:#f0e6d2
    style DB fill:#0a0e14,stroke:#ff5b1f,color:#f0e6d2
    style Anthropic fill:#1a2030,stroke:#a8a397,color:#f0e6d2

Use Case Diagram

graph LR
    Visitor((Anonymous<br/>Visitor))
    Builder((PC Builder<br/>signed in M2+))
    Operator((Operator<br/>MASTER))
    Merchant((Lebanese<br/>Retailer))

    subgraph M1["M1 Scope (this build)"]
        UC1[Browse parts by category]
        UC2[View product price comparison]
        UC3[Filter by socket, brand, price, in-stock]
        UC4[Click through to retailer]
        UC5[Build a PC — fill any slot in any order]
        UC6[See live compatibility status]
        UC7[See cheapest multi-store split]
        UC8[Share build via URL]
    end

    subgraph M2["M2 Scope (deferred)"]
        UC9[Sign in / save build to account]
        UC10[Get notified on price drops]
        UC11[Browse community builds]
        UC12[Self-onboard as retailer]
        UC13[Manage retailer products in backoffice]
        UC14[See click-to-conversion analytics]
    end

    subgraph Ops["Operator-only"]
        UC15[Run scrapers manually]
        UC16[Inspect scraper health]
        UC17[Reconcile commissions]
    end

    Visitor --> UC1
    Visitor --> UC2
    Visitor --> UC3
    Visitor --> UC4
    Visitor --> UC5
    Visitor --> UC6
    Visitor --> UC7
    Visitor --> UC8

    Builder --> UC9
    Builder --> UC10
    Builder --> UC11

    Merchant --> UC12
    Merchant --> UC13
    Merchant --> UC14

    Operator --> UC15
    Operator --> UC16
    Operator --> UC17

Activity Diagram — Build a PC (M1 happy path)

flowchart TD
    Start([User lands on /build])
    Empty[See empty build with 8 component slots:<br/>CPU, Cooler, MB, RAM, Storage, GPU, Case, PSU]
    Pick{Pick which slot to fill first?}

    Open[Open Choose-Component sheet]
    Filter[Filter by spec, brand, retailer, price]
    Sort[Sort by price/popularity]
    Search[Search by model name]
    Select[Click Add to build]

    Update[Slot fills with selection<br/>Live total updates<br/>Wattage estimate updates<br/>Compatibility re-runs]

    Compat{Any blocking incompatibility?}
    Warn[Show red warning row + reason]
    OK[Show green check]

    More{Add another component?}
    Done[Build is buyable]
    Summary[Click View build summary]

    Multi{Single store or split?}
    SingleStore[Show single-store cart]
    SplitStore[Show split:<br/>Mojitech 4 items $1,847<br/>961Souq 2 items $349<br/>Macrotronics 1 item $89<br/>TOTAL $2,285]

    Buy[Click Buy at retailer]
    Track[POST /api/go/r/X/p/Y<br/>Insert Click row]
    Redirect[302 → retailer URL with subid token]

    Start --> Empty
    Empty --> Pick
    Pick -->|CPU first| Open
    Pick -->|Case first| Open
    Pick -->|GPU first| Open
    Open --> Filter
    Filter --> Sort
    Sort --> Search
    Search --> Select
    Select --> Update
    Update --> Compat
    Compat -->|Yes| Warn
    Compat -->|No| OK
    Warn --> More
    OK --> More
    More -->|Yes| Pick
    More -->|No| Done
    Done --> Summary
    Summary --> Multi
    Multi -->|Single| SingleStore
    Multi -->|Split| SplitStore
    SingleStore --> Buy
    SplitStore --> Buy
    Buy --> Track
    Track --> Redirect

Database Schema (M1) — ERD

erDiagram
    Retailer ||--o{ Listing : has
    Retailer ||--o{ Click : receives
    Product ||--o{ Listing : matches
    Listing ||--o{ ListingPrice : tracks
    Listing ||--o{ Click : redirects
    Build ||--o{ BuildSlot : contains
    Product ||--o{ BuildSlot : referenced_by

    Retailer {
        uuid id PK
        string name
        string slug UK
        string domain
        string scraperId UK
        boolean active
        timestamp createdAt
        timestamp updatedAt
    }

    Product {
        uuid id PK
        Category category
        string brand
        string model
        string slug UK
        string cpuSocket "AM5, LGA1851, etc"
        string ramType "DDR4, DDR5"
        int ramSpeedMhz
        string formFactor "ATX, mATX, ITX, SFX"
        int tdpWatts
        int lengthMm
        int heightMm
        int psuWattage
        json specs "long-tail spec data"
        string imageUrl
        date releaseDate
        decimal msrpUsd
        timestamp createdAt
        timestamp updatedAt
    }

    Listing {
        uuid id PK
        uuid retailerId FK
        uuid productId FK "nullable"
        string url
        string retailerSku
        string titleRaw
        float matchConfidence
        string matchStatus "auto/manual/unmatched"
        timestamp lastSeenAt
        timestamp createdAt
        timestamp updatedAt
    }

    ListingPrice {
        uuid id PK
        uuid listingId FK
        decimal priceUsd
        boolean inStock
        timestamp scrapedAt
    }

    Click {
        uuid id PK
        string clickToken UK
        uuid retailerId FK
        uuid listingId FK
        string source "product_page, build_summary, etc"
        string ipHash
        string ua
        string referrer
        timestamp createdAt
    }

    Build {
        uuid id PK
        string slug UK "shareable URL"
        string title
        json totals "wattage, USD, multi-store flag"
        timestamp createdAt
        timestamp updatedAt
    }

    BuildSlot {
        uuid id PK
        uuid buildId FK
        Category slotCategory
        uuid productId FK "nullable until filled"
        uuid preferredListingId FK "which retailer for cheapest"
        int orderIndex
    }

New tables vs v0.1: - Build — persisted build (URL-shareable, anonymous in M1, account-linked in M2) - BuildSlot — one row per component slot in a build

This enables: shareable build URLs, build gallery, "save build" without auth (anonymous, addressable by slug), price-drop alerts on saved builds (M2).


Activity Diagram — Scraper Pipeline (operator-triggered M1, scheduled M2)

flowchart TD
    Trigger([Operator runs npm run scrape<br/>OR BullMQ cron in M2])
    Loop[For each retailer × each category]
    Fetch[fetchHtml URL with 961techBot UA]
    Parse[Run per-site parser → ScrapedListing array]

    Match{For each listing, run matcher}
    HighConf{Confidence ≥ 0.85?}
    AutoMatch[matchStatus = auto, link to product]
    LLMMatch[Route to LLM matcher<br/>Claude Haiku with brand/model normalization]
    LLMResult{LLM finds match?}
    LLMAuto[matchStatus = llm-auto]
    Unmatch[matchStatus = unmatched, productId null]

    Upsert[Listing upsert by retailerId+url]
    PriceInsert[ListingPrice insert always — price history]

    ImageCheck{Has image URL?}
    DownloadImg[Download image → upload to R2 → store CDN URL]
    SkipImg[Continue]

    EndLoop{More listings?}
    Health[Update scraper health metrics]
    Alert{Drift > 30%?}
    Telegram[Send Telegram alert]
    OK[Done]

    Trigger --> Loop
    Loop --> Fetch
    Fetch -->|HTTP 200| Parse
    Fetch -->|fail| Health
    Parse --> Match
    Match --> HighConf
    HighConf -->|Yes| AutoMatch
    HighConf -->|No| LLMMatch
    LLMMatch --> LLMResult
    LLMResult -->|Yes| LLMAuto
    LLMResult -->|No| Unmatch
    AutoMatch --> Upsert
    LLMAuto --> Upsert
    Unmatch --> Upsert
    Upsert --> PriceInsert
    PriceInsert --> ImageCheck
    ImageCheck -->|Yes| DownloadImg
    ImageCheck -->|No| SkipImg
    DownloadImg --> EndLoop
    SkipImg --> EndLoop
    EndLoop -->|Yes| Match
    EndLoop -->|No| Health
    Health --> Alert
    Alert -->|Yes| Telegram
    Alert -->|No| OK
    Telegram --> OK

M1 scope: matcher uses fuzzy token-overlap only (no LLM yet). Image download is deferred. Health/Telegram is M2.

M1.5 enhancements (within this rebuild scope): - LLM matcher fallback for low-confidence listings (especially AIB GPUs like "ASUS ROG STRIX RTX 4070" → "NVIDIA RTX 4070") - Image scraping into R2 (or just storing image URLs from retailer site as a starter) - All 8 component categories scraped (not just 3)


Component-Level Compatibility Rules (M1.5)

graph LR
    subgraph Rules["Compatibility Engine"]
        R1[CPU↔MB socket match]
        R2[RAM↔MB type match]
        R3[RAM↔MB speed support]
        R4[PSU wattage ≥ sum component TDP × 1.3]
        R5[GPU length ≤ Case max GPU length]
        R6[CPU cooler height ≤ Case max cooler height]
        R7[CPU cooler socket compat with CPU socket]
        R8[Storage M.2 fits MB M.2 slots]
        R9[Storage SATA fits MB SATA slots]
        R10[Case form factor accepts MB form factor]
    end

    subgraph Inputs["Inputs from BuildSlot selections"]
        CPU[CPU spec]
        Cooler[Cooler spec]
        MB[Motherboard spec]
        RAM[RAM kit spec]
        Storage[Storage spec]
        GPU[GPU spec]
        Case[Case spec]
        PSU[PSU spec]
    end

    subgraph Output["Result"]
        Status[CompatibilityCheck<br/>overall ok/warn/block<br/>per-rule reason]
    end

    CPU --> R1
    MB --> R1

    RAM --> R2
    MB --> R2

    RAM --> R3
    MB --> R3

    CPU --> R4
    GPU --> R4
    PSU --> R4

    GPU --> R5
    Case --> R5

    Cooler --> R6
    Case --> R6

    Cooler --> R7
    CPU --> R7

    Storage --> R8
    MB --> R8

    Storage --> R9
    MB --> R9

    Case --> R10
    MB --> R10

    R1 --> Status
    R2 --> Status
    R3 --> Status
    R4 --> Status
    R5 --> Status
    R6 --> Status
    R7 --> Status
    R8 --> Status
    R9 --> Status
    R10 --> Status

Rule severity: R1 (socket), R2 (RAM type), R5 (GPU clearance), R6 (cooler clearance), R7 (cooler socket), R10 (form factor) → block. R3 (RAM speed), R4 (PSU wattage) → warn. R8/R9 (storage slot count) → warn (degraded performance, not unbuilable).


Routes (M1.5)

graph TD
    Root["/"] --> Landing[Landing Page]

    Browse["/products"] --> CategoryFilter[Category-filtered list]
    BrowseDetail["/products/[slug]"] --> Detail[Product detail with prices, history, related]

    Build["/build"] --> BuildPage[All slots, live totals, compat panel]
    BuildSlug["/build/[slug]"] --> SavedBuild[Persisted build by URL slug]
    BuildChoose["/build/choose/[category]?slot=X"] --> Choose[Sheet/page for picking a component]

    Guides["/guides"] --> GuideList[Build guides index]
    GuideDetail["/guides/[slug]"] --> Guide[Single build guide]

    Retailers["/retailers"] --> RetailerList[All Lebanese retailers we index]
    RetailerDetail["/retailers/[slug]"] --> RetailerPage[Retailer profile]

    About["/about"] --> AboutPage[About 961tech]

    API["/api"]
    APIGo["/api/go/r/[r]/p/[l]"] -.302.-> External[(Retailer site)]
    APIBuildSave["/api/build/save"] --> SaveBuildJSON[Persist build, return slug]
    APIHealth["/api/health"] --> HealthJSON[Service status JSON]

    API --> APIGo
    API --> APIBuildSave
    API --> APIHealth

M1.5 new routes: - /build/[slug] — load saved build by URL slug (replaces query-param-only URLs) - /build/choose/[category] — full page for picking a component (alternative to in-page sheet) - /guides and /guides/[slug] — editorial build guides - /retailers and /retailers/[slug] — retailer profile pages


State Management

State Where Why
Server data (products, listings, prices) Postgres via Prisma, fetched in RSC Single source of truth
Current build draft (unsaved) Zustand client store + localStorage persistence Anonymous, survives refresh
Filter selections (browse) URL search params Shareable, browser-back-friendly
Compatibility results Computed from build draft, stored alongside it Re-runs on every change
Toast notifications Zustand global slice Cross-component access
Theme (M2) localStorage + CSS variable swap Default dark only in M1

Performance Targets

Metric Target
LCP (Largest Contentful Paint) < 1.8s on Lebanese 4G
TBT (Total Blocking Time) < 200ms
CLS (Cumulative Layout Shift) < 0.05
Interaction to Next Paint < 200ms
Bundle (JS, gzipped) < 200KB above-the-fold
Image weight (per page) < 800KB total, all WebP/AVIF

Achieved via: RSC (server-rendered), aggressive caching, Cloudflare CDN, image optimization, lazy-loaded below-fold components.


Implementation Phases (this rebuild)

gantt
    title 961tech v1.0 Rebuild — Iteration Plan
    dateFormat HH:mm
    section Foundation
    Design system docs           :done, des1, 00:00, 30m
    Architecture diagrams        :done, des2, after des1, 30m
    Page specs                   :des3, after des2, 30m
    Install design deps + fonts  :imp1, after des3, 30m
    Design tokens + theme        :imp2, after imp1, 30m
    Base components (button/card/etc)  :imp3, after imp2, 90m
    section Pages — pass 1
    Layout + Nav + Footer        :p1, after imp3, 60m
    Landing page                 :p2, after p1, 90m
    Browse page                  :p3, after p2, 90m
    Product detail               :p4, after p3, 60m
    Build page (all slots)       :crit, p5, after p4, 150m
    Choose component sheet       :p6, after p5, 60m
    Build summary                :p7, after p6, 60m
    section Backend — parallel
    Expand scrapers (8 categories) :scrap1, after imp3, 120m
    Expand seed (50+ per cat)    :seed1, after scrap1, 60m
    LLM matcher fallback         :match1, after seed1, 90m
    Image scraping into R2       :img1, after match1, 60m
    PSU/case/cooler compat rules :rules1, after img1, 90m
    section Polish
    Self-criticize round 1       :crit1, after p7, 30m
    Fix top issues round 1       :fix1, after crit1, 120m
    Self-criticize round 2       :crit2, after fix1, 30m
    Fix top issues round 2       :fix2, after crit2, 120m

Risk Register

Risk Mitigation
Time spent on design without working code Boxed iteration — each page gets max 90 min in pass 1
Animation overuse → performance regression Lighthouse check after each iteration
Scope creep (more pages, more features) Scope locked: 6 pages (landing, browse, detail, build, choose, summary). M2 owns rest.
Aesthetic divergence from spec Re-read design system doc at the start of each iteration
Backend changes break frontend Change DB/API in dedicated commits; frontend always builds against current schema