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 |