From d03adfe7fd237a94a677c0ea96f807d7053ea76f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 13:51:59 +0000 Subject: [PATCH 1/2] feat(pcb-portal): QodeX PCB website, customer portal and 3D Studio Add pcb-portal/, an English-only international website and ordering portal for the QodeX PCB routing service. Routing runs offline on the operator's workstation; the site handles order intake, USD payments, file exchange and in-browser 3D review. - Server-rendered public site with SEO metadata, sitemap, robots and llms.txt: home, how it works, 3D Studio, use cases (six domains), KiCad integration, pricing estimator, evidence library, docs, trust & security, about, careers, contact, legal. - Ordering: .kicad_pcb parsed in the browser for preview and quote, and on the server (authoritative for pricing). - Payments in USD: Stripe Checkout with signed webhook, PayPal Orders v2; server-to-server verification, idempotent finalisation, receipts. - 3D viewer for .kicad_pcb (KiCad 5-9), glb, gltf, stl, obj, wrl: layers, mask transparency, exploded stack, inspect with net highlighting, measuring tool. - Customer portal and admin panel (queue, pricing, deliverables, customers, inquiries, tariffs). - 38 node:test tests; CI job added. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017BQf54SD6EFwPyF5xPtqv8 --- .github/workflows/ci.yml | 22 + README.md | 2 + pcb-portal/.env.example | 33 + pcb-portal/.gitignore | 3 + pcb-portal/README.md | 107 ++ pcb-portal/package-lock.json | 1416 +++++++++++++++++ pcb-portal/package.json | 21 + pcb-portal/public/css/app.css | 565 +++++++ pcb-portal/public/favicon.svg | 1 + pcb-portal/public/js/app.js | 159 ++ pcb-portal/public/js/chrome.js | 16 + pcb-portal/public/js/order-view.js | 278 ++++ pcb-portal/public/js/pages/admin-customers.js | 15 + pcb-portal/public/js/pages/admin-inquiries.js | 25 + pcb-portal/public/js/pages/admin-order.js | 17 + pcb-portal/public/js/pages/admin-orders.js | 45 + pcb-portal/public/js/pages/admin-settings.js | 64 + pcb-portal/public/js/pages/auth.js | 24 + pcb-portal/public/js/pages/billing.js | 26 + pcb-portal/public/js/pages/contact.js | 26 + pcb-portal/public/js/pages/dashboard.js | 40 + pcb-portal/public/js/pages/evidence.js | 39 + pcb-portal/public/js/pages/file-viewer.js | 13 + pcb-portal/public/js/pages/home.js | 197 +++ pcb-portal/public/js/pages/new-order.js | 201 +++ pcb-portal/public/js/pages/order.js | 17 + pcb-portal/public/js/pages/pay-mock.js | 20 + pcb-portal/public/js/pages/pricing.js | 50 + pcb-portal/public/js/pages/receipt.js | 32 + pcb-portal/public/js/pages/settings.js | 27 + pcb-portal/public/js/pages/studio.js | 41 + pcb-portal/public/js/viewer/kicad-parser.js | 579 +++++++ pcb-portal/public/js/viewer/kicad-scene.js | 464 ++++++ pcb-portal/public/js/viewer/viewer.js | 584 +++++++ pcb-portal/public/og-image.svg | 15 + .../public/samples/demo-board.kicad_pcb | 287 ++++ pcb-portal/scripts/create-admin.js | 25 + pcb-portal/scripts/make-demo-board.js | 228 +++ pcb-portal/server/app.js | 253 +++ pcb-portal/server/auth.js | 122 ++ pcb-portal/server/config.js | 74 + pcb-portal/server/content.js | 192 +++ pcb-portal/server/db.js | 142 ++ pcb-portal/server/index.js | 14 + pcb-portal/server/layout.js | 188 +++ pcb-portal/server/payments/index.js | 30 + pcb-portal/server/payments/paypal.js | 97 ++ pcb-portal/server/payments/service.js | 100 ++ pcb-portal/server/payments/stripe.js | 79 + pcb-portal/server/pricing.js | 215 +++ pcb-portal/server/routes/admin.js | 157 ++ pcb-portal/server/routes/api.js | 360 +++++ pcb-portal/server/routes/shared.js | 159 ++ pcb-portal/server/storage.js | 67 + pcb-portal/test/api.test.js | 365 +++++ pcb-portal/test/kicad-parser.test.js | 103 ++ pcb-portal/test/make-glb.js | 45 + pcb-portal/test/pricing.test.js | 63 + pcb-portal/views/admin/customers.html | 3 + pcb-portal/views/admin/inquiries.html | 3 + pcb-portal/views/admin/order.html | 2 + pcb-portal/views/admin/orders.html | 10 + pcb-portal/views/admin/settings.html | 12 + pcb-portal/views/portal/billing.html | 4 + pcb-portal/views/portal/dashboard.html | 10 + pcb-portal/views/portal/file-viewer.html | 2 + pcb-portal/views/portal/new-order.html | 63 + pcb-portal/views/portal/order.html | 2 + pcb-portal/views/portal/pay-mock.html | 8 + pcb-portal/views/portal/receipt.html | 3 + pcb-portal/views/portal/settings.html | 21 + pcb-portal/views/site/404.html | 9 + pcb-portal/views/site/about.html | 32 + pcb-portal/views/site/careers.html | 21 + pcb-portal/views/site/contact.html | 36 + pcb-portal/views/site/docs.html | 127 ++ pcb-portal/views/site/evidence.html | 50 + pcb-portal/views/site/home.html | 189 +++ pcb-portal/views/site/how-it-works.html | 123 ++ pcb-portal/views/site/integrations.html | 71 + pcb-portal/views/site/legal.html | 74 + pcb-portal/views/site/login.html | 20 + pcb-portal/views/site/pricing.html | 71 + pcb-portal/views/site/register.html | 26 + pcb-portal/views/site/studio.html | 30 + pcb-portal/views/site/trust.html | 63 + pcb-portal/views/site/use-case.html | 36 + pcb-portal/views/site/use-cases.html | 14 + 88 files changed, 9684 insertions(+) create mode 100644 pcb-portal/.env.example create mode 100644 pcb-portal/.gitignore create mode 100644 pcb-portal/README.md create mode 100644 pcb-portal/package-lock.json create mode 100644 pcb-portal/package.json create mode 100644 pcb-portal/public/css/app.css create mode 100644 pcb-portal/public/favicon.svg create mode 100644 pcb-portal/public/js/app.js create mode 100644 pcb-portal/public/js/chrome.js create mode 100644 pcb-portal/public/js/order-view.js create mode 100644 pcb-portal/public/js/pages/admin-customers.js create mode 100644 pcb-portal/public/js/pages/admin-inquiries.js create mode 100644 pcb-portal/public/js/pages/admin-order.js create mode 100644 pcb-portal/public/js/pages/admin-orders.js create mode 100644 pcb-portal/public/js/pages/admin-settings.js create mode 100644 pcb-portal/public/js/pages/auth.js create mode 100644 pcb-portal/public/js/pages/billing.js create mode 100644 pcb-portal/public/js/pages/contact.js create mode 100644 pcb-portal/public/js/pages/dashboard.js create mode 100644 pcb-portal/public/js/pages/evidence.js create mode 100644 pcb-portal/public/js/pages/file-viewer.js create mode 100644 pcb-portal/public/js/pages/home.js create mode 100644 pcb-portal/public/js/pages/new-order.js create mode 100644 pcb-portal/public/js/pages/order.js create mode 100644 pcb-portal/public/js/pages/pay-mock.js create mode 100644 pcb-portal/public/js/pages/pricing.js create mode 100644 pcb-portal/public/js/pages/receipt.js create mode 100644 pcb-portal/public/js/pages/settings.js create mode 100644 pcb-portal/public/js/pages/studio.js create mode 100644 pcb-portal/public/js/viewer/kicad-parser.js create mode 100644 pcb-portal/public/js/viewer/kicad-scene.js create mode 100644 pcb-portal/public/js/viewer/viewer.js create mode 100644 pcb-portal/public/og-image.svg create mode 100644 pcb-portal/public/samples/demo-board.kicad_pcb create mode 100644 pcb-portal/scripts/create-admin.js create mode 100644 pcb-portal/scripts/make-demo-board.js create mode 100644 pcb-portal/server/app.js create mode 100644 pcb-portal/server/auth.js create mode 100644 pcb-portal/server/config.js create mode 100644 pcb-portal/server/content.js create mode 100644 pcb-portal/server/db.js create mode 100644 pcb-portal/server/index.js create mode 100644 pcb-portal/server/layout.js create mode 100644 pcb-portal/server/payments/index.js create mode 100644 pcb-portal/server/payments/paypal.js create mode 100644 pcb-portal/server/payments/service.js create mode 100644 pcb-portal/server/payments/stripe.js create mode 100644 pcb-portal/server/pricing.js create mode 100644 pcb-portal/server/routes/admin.js create mode 100644 pcb-portal/server/routes/api.js create mode 100644 pcb-portal/server/routes/shared.js create mode 100644 pcb-portal/server/storage.js create mode 100644 pcb-portal/test/api.test.js create mode 100644 pcb-portal/test/kicad-parser.test.js create mode 100644 pcb-portal/test/make-glb.js create mode 100644 pcb-portal/test/pricing.test.js create mode 100644 pcb-portal/views/admin/customers.html create mode 100644 pcb-portal/views/admin/inquiries.html create mode 100644 pcb-portal/views/admin/order.html create mode 100644 pcb-portal/views/admin/orders.html create mode 100644 pcb-portal/views/admin/settings.html create mode 100644 pcb-portal/views/portal/billing.html create mode 100644 pcb-portal/views/portal/dashboard.html create mode 100644 pcb-portal/views/portal/file-viewer.html create mode 100644 pcb-portal/views/portal/new-order.html create mode 100644 pcb-portal/views/portal/order.html create mode 100644 pcb-portal/views/portal/pay-mock.html create mode 100644 pcb-portal/views/portal/receipt.html create mode 100644 pcb-portal/views/portal/settings.html create mode 100644 pcb-portal/views/site/404.html create mode 100644 pcb-portal/views/site/about.html create mode 100644 pcb-portal/views/site/careers.html create mode 100644 pcb-portal/views/site/contact.html create mode 100644 pcb-portal/views/site/docs.html create mode 100644 pcb-portal/views/site/evidence.html create mode 100644 pcb-portal/views/site/home.html create mode 100644 pcb-portal/views/site/how-it-works.html create mode 100644 pcb-portal/views/site/integrations.html create mode 100644 pcb-portal/views/site/legal.html create mode 100644 pcb-portal/views/site/login.html create mode 100644 pcb-portal/views/site/pricing.html create mode 100644 pcb-portal/views/site/register.html create mode 100644 pcb-portal/views/site/studio.html create mode 100644 pcb-portal/views/site/trust.html create mode 100644 pcb-portal/views/site/use-case.html create mode 100644 pcb-portal/views/site/use-cases.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea895b1..ceb115d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,3 +52,25 @@ jobs: # excluded via STANDALONE_SCRIPTS in vitest.config.ts, so this is clean. - name: Test run: npm test + + pcb-portal: + name: PCB portal tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: pcb-portal + steps: + - uses: actions/checkout@v5 + + - name: Use Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: pcb-portal/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test diff --git a/README.md b/README.md index 24414bf..7363f4e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ --- +> **QodeX PCB portal** — a customer ordering / payment / 3D-viewer site for the QodeX PCB routing service lives in [`pcb-portal/`](pcb-portal/README.md). + ## What makes it different Most agentic CLIs *delegate to the model* — they hand the model tools and trust it to use them well. That works with a frontier model and falls apart with a weaker local one (loops, half-finished edits, "done" when nothing was tested). diff --git a/pcb-portal/.env.example b/pcb-portal/.env.example new file mode 100644 index 0000000..44c900d --- /dev/null +++ b/pcb-portal/.env.example @@ -0,0 +1,33 @@ +# ── QodeX PCB portal — copy to .env and fill in ───────────────────────────── +PORT=8787 +# Public URL customers use. Payment providers redirect back here. Use https in production. +PUBLIC_BASE_URL=http://localhost:8787 +SITE_NAME=QodeX PCB +COMPANY_NAME=QodeX Systems Lab +SUPPORT_EMAIL= +# SQLite database + uploaded customer files. Back this folder up. +DATA_DIR=./data +MAX_UPLOAD_MB=200 +# true when running behind nginx / Cloudflare / a load balancer +TRUST_PROXY=false + +# First admin account (created on startup if it does not exist) +ADMIN_EMAIL= +ADMIN_PASSWORD= +ADMIN_NAME=QodeX Admin + +# ── Payments (all prices are in USD) ──────────────────────────────────────── +# Comma-separated list of enabled checkouts, shown to the customer in this order: +# stripe → cards, Apple Pay, Google Pay, Link (Stripe Checkout) +# paypal → PayPal balance, PayPal Pay Later, cards via PayPal +# mock → built-in test checkout for development (never enable in production) +PAYMENT_PROVIDERS=mock + +STRIPE_SECRET_KEY= +# Signing secret of the webhook endpoint https:///api/payments/stripe/webhook +# (events: checkout.session.completed, checkout.session.async_payment_succeeded) +STRIPE_WEBHOOK_SECRET= + +PAYPAL_CLIENT_ID= +PAYPAL_CLIENT_SECRET= +PAYPAL_SANDBOX=false diff --git a/pcb-portal/.gitignore b/pcb-portal/.gitignore new file mode 100644 index 0000000..9f77d70 --- /dev/null +++ b/pcb-portal/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.env diff --git a/pcb-portal/README.md b/pcb-portal/README.md new file mode 100644 index 0000000..ba7b3e6 --- /dev/null +++ b/pcb-portal/README.md @@ -0,0 +1,107 @@ +# QodeX PCB — website, customer portal and 3D Studio + +The public website and ordering portal for the QodeX PCB routing service. **Routing does not run on the website** — the QodeX engine runs offline on your own workstation. The site takes care of everything around it: + +- **Marketing site** (server-rendered, SEO-ready): home, how it works, 3D Studio, six use-case pages, KiCad integration, pricing estimator, evidence library, docs & DFM academy, trust & security, about, careers, contact, legal. Canonical URLs, Open Graph, JSON-LD, `sitemap.xml`, `robots.txt` and `llms.txt` are generated. +- **Ordering:** customers drop a `.kicad_pcb` (plus any project files). The board is parsed in the browser for an instant 3D preview and quote, and parsed again on the server, whose geometry is authoritative for pricing. +- **Payments in USD:** Stripe Checkout (cards, Apple Pay, Google Pay, Link) and PayPal (Orders v2). Customers choose at checkout. Payments are verified server-to-server, callbacks are idempotent, and a signed Stripe webhook covers customers who close the tab. Printable receipts in the portal. +- **3D Studio / viewer:** `.kicad_pcb` (KiCad 5–9), `.glb`, `.gltf`, `.stl`, `.obj`, `.wrl`. Layer toggles, solder-mask transparency, exploded stack, **inspect** (hover to identify pads/vias/tracks/parts, click to highlight a whole net with its routed length), **measure** (distance, ΔX/ΔY, mils), auto-rotate, screenshot, fullscreen. +- **Customer portal:** dashboard, new order, order detail (status steps, deliverables with 3D view, engineer message thread, activity log), billing & receipts, account settings. +- **Admin panel:** order queue with stats and search, pricing/status/internal notes, deliverable upload (optionally marking the order delivered), customers, contact-form inbox, tariff editor. + +## Your workflow + +``` +Customer uploads board + pays (USD) ─► Admin queue: "Paid — queued" + │ + download customer files ◄──────────┘ + route on your workstation with the QodeX engine + upload deliverables (.kicad_pcb, .glb, gerber zip, dossier.pdf) + tick "Mark as delivered" + │ +Customer: 3D review, download, confirm ◄─┘ +``` + +Generate a GLB from a routed board with KiCad 8/9: `kicad-cli pcb export glb -o board.glb board.kicad_pcb` (the `.kicad_pcb` itself also opens directly in the viewer). + +## Setup + +Requires Node.js 20+. + +```bash +cd pcb-portal +npm install +cp .env.example .env # then edit it +npm start # http://localhost:8787 +``` + +| Variable | Purpose | +|---|---| +| `PUBLIC_BASE_URL` | Public URL of the site; payment providers redirect back here. Use `https://` in production. | +| `SITE_NAME`, `COMPANY_NAME`, `SUPPORT_EMAIL` | Shown in the header, footer, receipts and legal pages. | +| `ADMIN_EMAIL`, `ADMIN_PASSWORD` | First admin account (created on startup). Or later: `npm run create-admin -- email password "Name"`. | +| `PAYMENT_PROVIDERS` | Comma-separated: `stripe`, `paypal`, `mock`. `mock` is a development-only test checkout. | +| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | Stripe API key and the signing secret of the webhook endpoint below. | +| `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_SANDBOX` | PayPal REST app credentials; `PAYPAL_SANDBOX=true` for testing. | +| `DATA_DIR` | SQLite database and uploaded files. **Back this folder up.** | +| `MAX_UPLOAD_MB`, `TRUST_PROXY` | Upload limit; set `TRUST_PROXY=true` behind nginx / Cloudflare. | + +### Stripe + +1. Create a restricted or secret key and set `STRIPE_SECRET_KEY`. +2. Add a webhook endpoint `https:///api/payments/stripe/webhook` for `checkout.session.completed` and `checkout.session.async_payment_succeeded`, and put its signing secret in `STRIPE_WEBHOOK_SECRET`. +3. Enable Apple Pay / Google Pay in the Stripe dashboard if you want them offered in Checkout. + +### PayPal + +Create a REST app in the PayPal developer dashboard, set `PAYPAL_CLIENT_ID` / `PAYPAL_CLIENT_SECRET`, and test with `PAYPAL_SANDBOX=true` before switching to live credentials. + +### Pricing + +After signing in as admin, open **Pricing** and set your tariffs (the defaults are starting points). Turn off automatic quotes if you prefer to price every order by hand: orders then wait in "Awaiting quote" until you set a price. Off-site payments (wire transfer, invoice) can be recorded by setting the order status to "Paid". + +### Production + +- Run behind a TLS reverse proxy (nginx, Caddy, Cloudflare Tunnel). With an `https://` base URL, cookies become `Secure` and HSTS is sent. +- Allow request bodies at least as large as `MAX_UPLOAD_MB` in the proxy. +- `NODE_ENV=production npm start` under pm2 / systemd / launchd. +- Remove `mock` from `PAYMENT_PROVIDERS`. +- Have the legal pages (`views/site/legal.html`) reviewed by counsel for your jurisdiction and company. + +## Order statuses + +Awaiting quote → Awaiting payment → Paid — queued → In progress → Delivered → Completed (or Cancelled). + +## Security + +- Files are stored under random names outside any public directory and served only through `/api/files/:id` to the order owner and admins. +- scrypt password hashing; HttpOnly + SameSite session cookies; CSRF guard via a required custom header; rate limits on auth and contact endpoints; strict CSP with no third-party scripts. +- Orders are marked paid only after server-side verification with the provider (amount, currency and reference must match). Repricing an order cancels any open checkout. Internal admin notes are never sent to customers. +- Upload extension allow-list, size cap and SHA-256 per file. + +## Layout + +``` +pcb-portal/ +├── server/ +│ ├── app.js routes, page rendering, SEO files, Stripe webhook, CSP +│ ├── layout.js server-side layout (header, footer, sidebar, meta, JSON-LD) +│ ├── content.js use-case page content +│ ├── routes/ api.js (auth, orders, files, payments, contact) · admin.js +│ ├── payments/ stripe.js · paypal.js · service.js (verify + finalise) · index.js (mock) +│ └── pricing.js spec validation and USD quote calculation +├── views/ site/, portal/, admin/ page fragments +├── public/ +│ ├── css/app.css design system +│ ├── js/viewer/ kicad-parser.js (shared with the server) · kicad-scene.js · viewer.js +│ ├── js/pages/ one script per page +│ └── samples/ open demo board (scripts/make-demo-board.js) +└── test/ node --test (npm test) +``` + +## Tests + +```bash +npm test +``` + +Covers the KiCad parser (KiCad 5 legacy through 9, rotations, THT/NPTH, arcs, nets), pricing, every public page (rendered, no unfilled placeholders), the full order/payment/delivery API including access control, the contact inbox, and PayPal and Stripe flows (including webhook signatures) against stubbed providers. diff --git a/pcb-portal/package-lock.json b/pcb-portal/package-lock.json new file mode 100644 index 0000000..bf3758c --- /dev/null +++ b/pcb-portal/package-lock.json @@ -0,0 +1,1416 @@ +{ + "name": "@qodex/pcb-portal", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@qodex/pcb-portal", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "better-sqlite3": "^11.5.0", + "express": "^5.1.0", + "multer": "^2.0.2", + "three": "^0.180.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/pcb-portal/package.json b/pcb-portal/package.json new file mode 100644 index 0000000..57510f1 --- /dev/null +++ b/pcb-portal/package.json @@ -0,0 +1,21 @@ +{ + "name": "@qodex/pcb-portal", + "version": "0.1.0", + "private": true, + "description": "QodeX PCB — customer ordering portal: order intake, online payment (Zarinpal / Stripe), file exchange and in-browser 3D viewer for KiCad & GLB boards. Routing itself runs offline on the QodeX engine.", + "type": "module", + "license": "Apache-2.0", + "engines": { "node": ">=20.0.0" }, + "scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js", + "create-admin": "node scripts/create-admin.js", + "test": "node --test test/*.test.js" + }, + "dependencies": { + "better-sqlite3": "^11.5.0", + "express": "^5.1.0", + "multer": "^2.0.2", + "three": "^0.180.0" + } +} diff --git a/pcb-portal/public/css/app.css b/pcb-portal/public/css/app.css new file mode 100644 index 0000000..9951d09 --- /dev/null +++ b/pcb-portal/public/css/app.css @@ -0,0 +1,565 @@ +/* QodeX PCB — laboratory design system (spec §4) */ +:root { + --surface-base: #08090a; + --surface-sunken: #050607; + --surface-elevated: #111317; + --surface-overlay: #191c24; + --surface-stroke: #262a36; + --surface-stroke-soft: #1b1e27; + + --copper-primary: #df8244; + --copper-glow: #ffb076; + --copper-muted: #5e3519; + + --signal-cyan: #2ce5e5; + --signal-cyan-glow: #7ef5f5; + + --drc-pass: #33d17a; + --drc-pass-surface: #0a2617; + --drc-warn: #f5a623; + --drc-warn-surface: #2b200a; + --drc-fail: #e5484d; + --drc-fail-surface: #2b1113; + + --text-pure: #f9f9fb; + --text-high: #e2e4e9; + --text-med: #9da3ae; + --text-low: #6f7684; + --text-disabled: #3d424d; + + --radius: 12px; + --radius-sm: 7px; + --font: 'Geist', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; + --mono: 'Geist Mono', 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace; + --ease: cubic-bezier(.2, .7, .2, 1); + color-scheme: dark; +} + +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; } +body { + margin: 0; + background: var(--surface-base); + color: var(--text-high); + font-family: var(--font); + font-size: 16px; + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; + -webkit-font-smoothing: antialiased; +} +main { flex: 1; } +img, svg, canvas { display: block; max-width: 100%; } +a { color: var(--copper-glow); text-decoration: none; } +a:hover { text-decoration: underline; text-underline-offset: 3px; } +code, kbd, .mono { font-family: var(--mono); font-size: .92em; } +code { color: var(--copper-glow); background: rgba(223, 130, 68, .08); padding: .1em .35em; border-radius: 4px; } +kbd { border: 1px solid var(--surface-stroke); border-bottom-width: 2px; border-radius: 4px; padding: 0 .4em; font-size: .8em; color: var(--text-high); } +pre { font-family: var(--mono); font-size: 13px; line-height: 1.6; background: var(--surface-sunken); border: 1px solid var(--surface-stroke); border-radius: var(--radius-sm); padding: 14px 16px; overflow-x: auto; color: var(--text-high); margin: 0 0 1em; } +pre code { background: none; padding: 0; color: inherit; } +h1, h2, h3, h4 { color: var(--text-pure); line-height: 1.2; margin: 0 0 .5em; letter-spacing: -.02em; font-weight: 700; } +h1 { font-size: clamp(32px, 4.2vw, 48px); } +h2 { font-size: clamp(26px, 3vw, 36px); } +h3 { font-size: 19px; letter-spacing: -.01em; } +h4 { font-size: 15px; } +p { margin: 0 0 1em; } +ul, ol { margin: 0 0 1em; padding-left: 1.2em; } +hr { border: 0; border-top: 1px solid var(--surface-stroke); margin: 24px 0; } +[hidden] { display: none !important; } +:focus-visible { outline: 2px solid var(--copper-glow); outline-offset: 2px; } +::selection { background: rgba(223, 130, 68, .35); color: #fff; } +.skip { position: absolute; left: -999px; top: 8px; z-index: 100; background: var(--copper-primary); color: #140a03; padding: 6px 12px; border-radius: 6px; } +.skip:focus { left: 8px; } + +.container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0 20px; } +.container--narrow { max-width: 820px; } +.muted { color: var(--text-med); } +.low { color: var(--text-low); font-size: 14px; } +.lead { font-size: 19px; color: var(--text-med); max-width: 680px; } +.eyebrow { display: inline-block; font-family: var(--mono); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--signal-cyan); margin-bottom: 12px; } +.accent { color: var(--copper-glow); } +.center { text-align: center; } +.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.row--end { justify-content: flex-end; } +.spacer { flex: 1; } +.stack > * + * { margin-top: 14px; } +.nowrap { white-space: nowrap; } + +/* ── header ─────────────────────────────────────────────── */ +.site-header { + position: sticky; top: 0; z-index: 40; + background: rgba(8, 9, 10, .82); + backdrop-filter: saturate(140%) blur(12px); + border-bottom: 1px solid var(--surface-stroke-soft); +} +.site-header__row { display: flex; align-items: center; gap: 24px; height: 64px; } +.brand { display: inline-flex; align-items: center; gap: 10px; color: var(--text-pure); font-weight: 700; font-size: 18px; letter-spacing: -.02em; } +.brand:hover { text-decoration: none; } +.brand svg { width: 30px; height: 30px; filter: drop-shadow(0 0 14px rgba(223, 130, 68, .35)); } +.brand em { font-style: normal; font-family: var(--mono); font-size: 12px; color: var(--copper-glow); margin-left: 6px; padding: 1px 6px; border: 1px solid var(--copper-muted); border-radius: 4px; vertical-align: 2px; } +.nav { display: flex; align-items: center; gap: 2px; flex: 1; } +.nav > a, .nav__trigger { + color: var(--text-med); padding: 8px 12px; border-radius: var(--radius-sm); + background: none; border: 0; font: inherit; font-size: 14.5px; cursor: pointer; +} +.nav > a:hover, .nav__trigger:hover, .nav > a.is-active, .nav__trigger.is-active { color: var(--text-pure); background: var(--surface-elevated); text-decoration: none; } +.nav__trigger::after { content: ''; display: inline-block; width: 6px; height: 6px; margin-left: 7px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg) translateY(-3px); opacity: .7; } +.nav__group { position: relative; } +.nav__menu { + position: absolute; top: calc(100% + 8px); left: 0; min-width: 300px; + background: var(--surface-overlay); border: 1px solid var(--surface-stroke); border-radius: var(--radius); + padding: 8px; box-shadow: 0 24px 60px rgba(0, 0, 0, .55); + opacity: 0; visibility: hidden; transform: translateY(-4px); transition: all .16s var(--ease); +} +.nav__menu::before { content: ''; position: absolute; inset: -10px 0 auto; height: 10px; } +.nav__menu--wide { display: grid; grid-template-columns: 1fr 1fr; min-width: 560px; } +.nav__group:hover .nav__menu, .nav__group:focus-within .nav__menu { opacity: 1; visibility: visible; transform: none; } +.nav__menu a { display: block; padding: 10px 12px; border-radius: var(--radius-sm); color: var(--text-high); } +.nav__menu a:hover { background: rgba(255, 255, 255, .04); text-decoration: none; } +.nav__menu strong { display: block; font-size: 14px; color: var(--text-pure); font-weight: 600; } +.nav__menu span { display: block; font-size: 12.5px; color: var(--text-low); } +.nav__account { margin-left: auto; display: flex; align-items: center; gap: 8px; } +.nav-signin { color: var(--text-med); font-size: 14.5px; padding: 8px 10px; } +.nav-signin:hover { color: var(--text-pure); text-decoration: none; } +.nav-toggle { display: none; margin-left: auto; background: none; border: 1px solid var(--surface-stroke); color: var(--text-high); border-radius: var(--radius-sm); width: 40px; height: 36px; cursor: pointer; padding: 7px; } +@media (max-width: 960px) { + .nav-toggle { display: block; } + .nav { + position: fixed; inset: 64px 0 auto 0; max-height: calc(100vh - 64px); overflow-y: auto; + flex-direction: column; align-items: stretch; gap: 4px; padding: 12px 20px 20px; + background: var(--surface-base); border-bottom: 1px solid var(--surface-stroke); + display: none; + } + .nav.is-open { display: flex; } + .nav__menu, .nav__menu--wide { position: static; opacity: 1; visibility: visible; transform: none; box-shadow: none; min-width: 0; grid-template-columns: 1fr; background: transparent; border: 0; padding: 0 0 0 10px; } + .nav__trigger { text-align: left; width: 100%; } + .nav__account { margin: 10px 0 0; flex-wrap: wrap; } +} + +/* ── buttons ────────────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + padding: 10px 18px; border-radius: var(--radius-sm); + border: 1px solid var(--surface-stroke); background: var(--surface-elevated); + color: var(--text-high); font: inherit; font-size: 15px; font-weight: 500; cursor: pointer; + transition: border-color .15s, background .15s, color .15s, transform .15s, box-shadow .15s; + white-space: nowrap; text-decoration: none; +} +.btn:hover { border-color: var(--copper-primary); text-decoration: none; color: var(--text-pure); } +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn--primary { background: var(--copper-primary); border-color: var(--copper-primary); color: #160b03; font-weight: 600; } +.btn--primary:hover { background: var(--copper-glow); border-color: var(--copper-glow); color: #160b03; box-shadow: 0 8px 30px rgba(223, 130, 68, .3); } +.btn--ghost { background: transparent; } +.btn--danger { border-color: rgba(229, 72, 77, .6); color: var(--drc-fail); background: transparent; } +.btn--danger:hover { border-color: var(--drc-fail); color: #fff; background: var(--drc-fail-surface); } +.btn--sm { padding: 6px 12px; font-size: 13.5px; } +.btn--lg { padding: 14px 26px; font-size: 16.5px; } +.btn--block { width: 100%; } +.link-arrow { color: var(--copper-glow); font-size: 14px; font-weight: 500; } +.link-arrow::after { content: ' →'; } + +/* ── sections / cards ───────────────────────────────────── */ +.section { padding: 96px 0; border-top: 1px solid var(--surface-stroke-soft); position: relative; } +.section--tight { padding: 64px 0; } +.section__head { max-width: 760px; margin-bottom: 44px; } +.section__head.center { margin-left: auto; margin-right: auto; } +.page-hero { padding: 88px 0 56px; position: relative; overflow: hidden; } +.page-hero::before, .hero::before { + content: ''; position: absolute; inset: 0; pointer-events: none; + background: + radial-gradient(ellipse 60% 50% at 80% 0%, rgba(223, 130, 68, .14), transparent 70%), + radial-gradient(ellipse 50% 50% at 0% 100%, rgba(44, 229, 229, .06), transparent 70%); +} +.page-hero::after, .hero::after { + content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .5; + background-image: linear-gradient(rgba(255, 255, 255, .025) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, .025) 1px, transparent 1px); + background-size: 40px 40px; + -webkit-mask-image: radial-gradient(ellipse 70% 60% at 50% 0%, #000, transparent); + mask-image: radial-gradient(ellipse 70% 60% at 50% 0%, #000, transparent); +} +.page-hero > .container, .hero > .container { position: relative; z-index: 1; } + +.card { + background: var(--surface-elevated); + border: 1px solid var(--surface-stroke); + border-radius: var(--radius); + padding: 24px; +} +.card--link { display: block; color: inherit; transition: border-color .2s, transform .2s var(--ease), background .2s; } +.card--link:hover { border-color: var(--copper-muted); background: #14161b; text-decoration: none; transform: translateY(-2px); } +.card__title { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } +.card__title h2, .card__title h3 { margin: 0; font-size: 17px; } +.stack-cards > .card + .card, .stack-cards > * + * { margin-top: 16px; } +.grid { display: grid; gap: 18px; } +.grid--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.grid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.split { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 24px; align-items: start; } +.split--even { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 56px; align-items: center; } +@media (max-width: 960px) { + .grid--3, .grid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .split, .split--even { grid-template-columns: minmax(0, 1fr); gap: 32px; } +} +@media (max-width: 640px) { + .grid--2, .grid--3, .grid--4 { grid-template-columns: minmax(0, 1fr); } + .section { padding: 64px 0; } + .card { padding: 18px; } +} + +.feature h3 { margin-top: 12px; } +.feature p { color: var(--text-med); margin: 0; font-size: 15px; } +.feature__num { font-family: var(--mono); font-size: 12px; color: var(--copper-primary); letter-spacing: .08em; } +.feature__icon { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center; background: rgba(223, 130, 68, .1); border: 1px solid var(--copper-muted); color: var(--copper-glow); font-family: var(--mono); font-size: 15px; } +.checklist { list-style: none; padding: 0; margin: 0; } +.checklist li { position: relative; padding: 8px 0 8px 28px; color: var(--text-high); border-bottom: 1px dashed var(--surface-stroke-soft); } +.checklist li:last-child { border-bottom: 0; } +.checklist li::before { content: ''; position: absolute; left: 2px; top: 14px; width: 12px; height: 7px; border-left: 2px solid var(--drc-pass); border-bottom: 2px solid var(--drc-pass); transform: rotate(-45deg); } +.chip { display: inline-block; padding: 5px 12px; border: 1px solid var(--surface-stroke); border-radius: 999px; font-size: 13.5px; color: var(--text-high); margin: 0 6px 8px 0; } +.chip:hover { border-color: var(--copper-primary); text-decoration: none; } +.uc-card p { color: var(--text-med); font-size: 14.5px; } + +/* ── hero ───────────────────────────────────────────────── */ +.hero { padding: 64px 0 40px; position: relative; overflow: hidden; } +.hero__grid { display: grid; grid-template-columns: minmax(0, .95fr) minmax(0, 1.15fr); gap: 48px; align-items: center; } +.hero h1 { font-size: clamp(40px, 5.6vw, 68px); line-height: 1.02; letter-spacing: -.045em; margin-bottom: 22px; } +.hero h1 .grad { background: linear-gradient(100deg, var(--copper-glow), var(--copper-primary) 45%, var(--signal-cyan) 115%); -webkit-background-clip: text; background-clip: text; color: transparent; } +.hero__cta { margin-top: 30px; } +.hero__proof { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 26px; font-size: 13.5px; color: var(--text-low); } +.hero__proof span::before { content: '●'; color: var(--drc-pass); font-size: 9px; margin-right: 7px; vertical-align: 2px; } +.hero__stage { position: relative; height: 560px; border-radius: 18px; border: 1px solid var(--surface-stroke); overflow: hidden; background: radial-gradient(ellipse at 50% 40%, #13161c, #07080a 70%); box-shadow: 0 40px 120px rgba(0, 0, 0, .6), inset 0 1px 0 rgba(255, 255, 255, .04); } +.hero__stage .qx-viewer { border-radius: 0; background: transparent; } +.hero__badge { position: absolute; right: 16px; bottom: 16px; z-index: 3; font-family: var(--mono); font-size: 11.5px; color: var(--signal-cyan); background: rgba(8, 9, 10, .75); border: 1px solid rgba(44, 229, 229, .3); padding: 5px 10px; border-radius: 999px; pointer-events: none; } +.hero__badge::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--signal-cyan); margin-right: 8px; box-shadow: 0 0 10px var(--signal-cyan); animation: pulse 1.8s infinite; } +@keyframes pulse { 50% { opacity: .35; } } +@media (max-width: 960px) { + .hero__grid { grid-template-columns: minmax(0, 1fr); } + .hero__stage { height: 400px; } +} + +/* telemetry strip */ +.telemetry { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--surface-stroke); border-radius: var(--radius); overflow: hidden; background: var(--surface-elevated); margin: 0; } +.telemetry > div { padding: 20px 22px; border-right: 1px solid var(--surface-stroke); } +.telemetry > div:last-child { border-right: 0; } +.telemetry dt { font-size: 12px; color: var(--text-low); text-transform: uppercase; letter-spacing: .08em; font-family: var(--mono); } +.telemetry dd { margin: 6px 0 0; font-family: var(--mono); font-size: 26px; color: var(--text-pure); font-weight: 500; } +.telemetry dd small { font-size: 13px; color: var(--text-low); margin-left: 4px; } +@media (max-width: 760px) { .telemetry { grid-template-columns: repeat(2, 1fr); } .telemetry > div:nth-child(2) { border-right: 0; } .telemetry > div:nth-child(-n+2) { border-bottom: 1px solid var(--surface-stroke); } } + +/* pipeline diagram */ +.pipeline { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); position: relative; } +.pipeline__step { position: relative; padding: 26px 22px; border: 1px solid var(--surface-stroke); background: var(--surface-elevated); } +.pipeline__step:first-child { border-radius: var(--radius) 0 0 var(--radius); } +.pipeline__step:last-child { border-radius: 0 var(--radius) var(--radius) 0; } +.pipeline__step + .pipeline__step { border-left: 0; } +.pipeline__step::after { content: ''; position: absolute; right: -8px; top: 36px; width: 14px; height: 14px; background: var(--surface-elevated); border-top: 1px solid var(--surface-stroke); border-right: 1px solid var(--surface-stroke); transform: rotate(45deg); z-index: 2; } +.pipeline__step:last-child::after { display: none; } +.pipeline__step h3 { font-size: 17px; margin: 12px 0 8px; } +.pipeline__step p { font-size: 14.5px; color: var(--text-med); margin: 0; } +.pipeline__tag { font-family: var(--mono); font-size: 11.5px; color: var(--signal-cyan); letter-spacing: .08em; } +@media (max-width: 960px) { + .pipeline { grid-template-columns: 1fr; } + .pipeline__step, .pipeline__step:first-child, .pipeline__step:last-child { border-radius: 0; border-left: 1px solid var(--surface-stroke); } + .pipeline__step:first-child { border-radius: var(--radius) var(--radius) 0 0; } + .pipeline__step:last-child { border-radius: 0 0 var(--radius) var(--radius); } + .pipeline__step + .pipeline__step { border-top: 0; } + .pipeline__step::after { right: 30px; top: auto; bottom: -8px; transform: rotate(135deg); } +} + +/* before / after comparison */ +.compare { position: relative; height: 400px; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--surface-stroke); background: #0b0d10; user-select: none; touch-action: none; cursor: ew-resize; } +.compare canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.compare__after { position: absolute; inset: 0; clip-path: inset(0 0 0 50%); } +.compare__handle { position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; background: var(--copper-glow); box-shadow: 0 0 20px var(--copper-primary); pointer-events: none; } +.compare__handle::after { content: '⟷'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 38px; height: 38px; border-radius: 50%; background: var(--copper-primary); color: #160b03; display: grid; place-items: center; font-size: 16px; font-weight: 700; } +.compare__label { position: absolute; top: 14px; font-family: var(--mono); font-size: 12px; padding: 4px 10px; border-radius: 999px; background: rgba(8, 9, 10, .8); border: 1px solid var(--surface-stroke); pointer-events: none; } +.compare__label--before { left: 14px; color: var(--drc-fail); } +.compare__label--after { right: 14px; color: var(--drc-pass); } +.compare__legend { display: flex; gap: 22px; flex-wrap: wrap; margin-top: 14px; font-size: 13.5px; color: var(--text-med); } +.compare__legend b { color: var(--text-pure); font-family: var(--mono); font-weight: 500; } + +/* fab grid */ +.fab-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } +.fab { padding: 20px; border: 1px solid var(--surface-stroke); border-radius: var(--radius); background: var(--surface-elevated); } +.fab h4 { margin: 0 0 4px; font-size: 16px; } +.fab p { font-size: 12.5px; color: var(--text-low); margin: 0 0 12px; } +.fab dl { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 5px 12px; font-size: 13px; } +.fab dt { color: var(--text-low); } +.fab dd { margin: 0; font-family: var(--mono); color: var(--text-high); } +@media (max-width: 960px) { .fab-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 560px) { .fab-grid { grid-template-columns: 1fr; } } + +/* spec strip */ +.specs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--surface-stroke); border-radius: var(--radius); overflow: hidden; margin: 0; } +.spec { padding: 18px 20px; border-right: 1px solid var(--surface-stroke); background: var(--surface-elevated); } +.spec:last-child { border-right: 0; } +.spec dt { font-size: 12px; color: var(--text-low); font-family: var(--mono); text-transform: uppercase; letter-spacing: .06em; } +.spec dd { margin: 6px 0 0; font-size: 17px; color: var(--text-pure); font-weight: 600; } +@media (max-width: 760px) { .specs { grid-template-columns: 1fr 1fr; } .spec { border-bottom: 1px solid var(--surface-stroke); } } + +/* faq */ +.faq { border-bottom: 1px solid var(--surface-stroke); padding: 4px 0; } +.faq summary { cursor: pointer; list-style: none; padding: 16px 36px 16px 0; font-weight: 600; color: var(--text-pure); position: relative; } +.faq summary::-webkit-details-marker { display: none; } +.faq summary::after { content: '+'; position: absolute; right: 4px; top: 11px; font-size: 22px; color: var(--copper-glow); transition: transform .2s; } +.faq[open] summary::after { transform: rotate(45deg); } +.faq p { color: var(--text-med); padding-right: 36px; } + +/* cta band */ +.cta-band { border: 1px solid var(--copper-muted); border-radius: 18px; padding: 60px 56px; text-align: center; background: radial-gradient(ellipse at 50% 0%, rgba(223, 130, 68, .18), transparent 70%), var(--surface-elevated); } +.cta-band h2 { max-width: 720px; margin: 0 auto .4em; } +.cta-band p { color: var(--text-med); max-width: 560px; margin: 0 auto 26px; } +.cta-band .row { justify-content: center; } +@media (max-width: 640px) { .cta-band { padding: 36px 22px; } } + +/* prose (docs / legal) */ +.prose { max-width: 760px; } +.prose h2 { font-size: 28px; margin-top: 56px; padding-top: 12px; scroll-margin-top: 80px; } +.prose h2:first-child { margin-top: 0; } +.prose h3 { font-size: 20px; margin-top: 32px; scroll-margin-top: 80px; } +.prose p, .prose li { color: var(--text-med); } +.prose strong { color: var(--text-high); } +.prose table { width: 100%; border-collapse: collapse; margin: 0 0 1.4em; font-size: 14.5px; } +.prose th, .prose td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--surface-stroke); vertical-align: top; } +.prose th { color: var(--text-low); font-weight: 500; font-size: 13px; } +.prose td { color: var(--text-med); } +.doc-layout { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 56px; align-items: start; } +.toc { position: sticky; top: 88px; font-size: 14px; } +.toc h4 { font-size: 11.5px; text-transform: uppercase; letter-spacing: .1em; color: var(--text-low); font-family: var(--mono); margin: 18px 0 8px; } +.toc h4:first-child { margin-top: 0; } +.toc a { display: block; color: var(--text-med); padding: 5px 0 5px 12px; border-left: 1px solid var(--surface-stroke); } +.toc a:hover { color: var(--text-pure); border-left-color: var(--copper-primary); text-decoration: none; } +@media (max-width: 900px) { .doc-layout { grid-template-columns: 1fr; } .toc { position: static; } } +.callout { border: 1px solid var(--surface-stroke); border-left: 3px solid var(--copper-primary); background: var(--surface-elevated); padding: 14px 18px; border-radius: var(--radius-sm); margin: 0 0 1.4em; color: var(--text-med); } +.callout strong { color: var(--text-pure); } +.callout--cyan { border-left-color: var(--signal-cyan); } + +/* ── forms ──────────────────────────────────────────────── */ +.field { display: block; } +.field__label { display: block; font-size: 13.5px; font-weight: 500; color: var(--text-med); margin-bottom: 6px; } +.field__hint { display: block; font-size: 12.5px; color: var(--text-low); margin-top: 5px; } +input[type=text], input[type=email], input[type=password], input[type=number], input[type=tel], input[type=search], input[type=url], select, textarea { + width: 100%; padding: 10px 12px; + background: var(--surface-sunken); color: var(--text-pure); + border: 1px solid var(--surface-stroke); border-radius: var(--radius-sm); + font: inherit; font-size: 15px; transition: border-color .15s, box-shadow .15s; +} +input::placeholder, textarea::placeholder { color: var(--text-disabled); } +input:focus, select:focus, textarea:focus { border-color: var(--copper-primary); outline: none; box-shadow: 0 0 0 3px rgba(223, 130, 68, .15); } +input:disabled { opacity: .55; } +input[type=number] { font-family: var(--mono); } +textarea { min-height: 110px; resize: vertical; } +select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--text-med) 50%), linear-gradient(135deg, var(--text-med) 50%, transparent 50%); background-position: calc(100% - 17px) 50%, calc(100% - 12px) 50%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 32px; } +input[type=range] { accent-color: var(--copper-primary); width: 100%; } +input[type=file] { color: var(--text-med); font-size: 14px; max-width: 100%; } +input[type=file]::file-selector-button { font: inherit; font-size: 13.5px; background: var(--surface-overlay); color: var(--text-high); border: 1px solid var(--surface-stroke); border-radius: 6px; padding: 6px 12px; margin-right: 10px; cursor: pointer; } +.check { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; font-size: 14.5px; color: var(--text-high); } +.check input { width: 17px; height: 17px; margin-top: 3px; accent-color: var(--copper-primary); flex: none; } +.form-error, .form-ok, .form-warn { border-radius: var(--radius-sm); padding: 10px 14px; font-size: 14px; } +.form-error { color: #ffb3b5; background: var(--drc-fail-surface); border: 1px solid rgba(229, 72, 77, .6); } +.form-ok { color: #9ff0c3; background: var(--drc-pass-surface); border: 1px solid rgba(51, 209, 122, .5); } +.form-warn { color: #ffd89a; background: var(--drc-warn-surface); border: 1px solid rgba(245, 166, 35, .5); } +.hp { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; } +.auth { min-height: calc(100vh - 64px); display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.auth__panel { display: flex; align-items: center; justify-content: center; padding: 48px 20px; } +.auth__box { width: 100%; max-width: 420px; } +.auth__box h1 { font-size: 32px; } +.auth__art { position: relative; border-left: 1px solid var(--surface-stroke); overflow: hidden; background: radial-gradient(ellipse at 30% 20%, rgba(223, 130, 68, .16), transparent 60%), #0a0b0d; display: flex; align-items: flex-end; padding: 48px; } +.auth__art blockquote { margin: 0; max-width: 460px; font-size: 23px; color: var(--text-pure); line-height: 1.4; letter-spacing: -.01em; position: relative; z-index: 1; } +.auth__art cite { display: block; margin-top: 14px; font-size: 13px; font-style: normal; color: var(--text-low); font-family: var(--mono); } +.auth__art .traces { position: absolute; inset: 0; width: 100%; height: 100%; opacity: .6; } +@media (max-width: 900px) { .auth { grid-template-columns: 1fr; } .auth__art { display: none; } } + +/* ── dropzone ───────────────────────────────────────────── */ +.dropzone { + position: relative; border: 1.5px dashed var(--surface-stroke); border-radius: var(--radius); + padding: 36px 24px; text-align: center; cursor: pointer; + background: + radial-gradient(circle at 20% 20%, rgba(223, 130, 68, .07), transparent 45%), + repeating-linear-gradient(90deg, rgba(223, 130, 68, .035) 0 1px, transparent 1px 24px), + repeating-linear-gradient(0deg, rgba(223, 130, 68, .025) 0 1px, transparent 1px 24px), + var(--surface-sunken); + transition: border-color .15s, transform .15s; +} +.dropzone:hover, .dropzone.is-drag { border-color: var(--copper-glow); } +.dropzone.is-drag { transform: scale(1.005); } +.dropzone__icon { width: 52px; height: 52px; margin: 0 auto 12px; border-radius: 14px; border: 1px solid var(--copper-muted); display: grid; place-items: center; color: var(--copper-glow); font-family: var(--mono); font-size: 13px; background: rgba(223, 130, 68, .08); } +.dropzone strong { color: var(--text-pure); } +.dropzone p { margin: 0 0 4px; } +.file-list { list-style: none; padding: 0; margin: 12px 0 0; } +.file-list li { display: flex; align-items: center; gap: 12px; padding: 9px 12px; border: 1px solid var(--surface-stroke); border-radius: var(--radius-sm); margin-top: 6px; background: var(--surface-sunken); font-size: 14px; } +.file-list .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 13.5px; } +.file-ext { font-family: var(--mono); font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--surface-overlay); color: var(--text-med); text-transform: uppercase; flex: none; } +.file-ext--3d { color: var(--signal-cyan); background: rgba(44, 229, 229, .08); } + +/* ── tables ─────────────────────────────────────────────── */ +.table-wrap { overflow-x: auto; } +table.table { width: 100%; border-collapse: collapse; font-size: 14px; } +.table th, .table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid var(--surface-stroke-soft); vertical-align: middle; } +.table th { color: var(--text-low); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; } +.table tbody tr:hover td { background: rgba(255, 255, 255, .018); } +.table tbody tr:last-child td { border-bottom: 0; } +.table a.rowlink { color: var(--text-pure); font-weight: 600; font-family: var(--mono); font-size: 13.5px; } +.table .num { font-family: var(--mono); text-align: right; } +.table .name-cell { font-family: var(--mono); font-size: 13.5px; min-width: 200px; overflow-wrap: anywhere; } + +/* ── badges ─────────────────────────────────────────────── */ +.badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; border: 1px solid var(--surface-stroke); color: var(--text-med); white-space: nowrap; } +.badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.badge--quote_pending, .badge--awaiting_payment, .badge--new, .badge--pending { color: var(--drc-warn); border-color: rgba(245, 166, 35, .4); background: var(--drc-warn-surface); } +.badge--paid, .badge--in_progress { color: var(--signal-cyan); border-color: rgba(44, 229, 229, .35); background: rgba(44, 229, 229, .06); } +.badge--delivered, .badge--completed, .badge--handled { color: var(--drc-pass); border-color: rgba(51, 209, 122, .4); background: var(--drc-pass-surface); } +.badge--cancelled, .badge--failed { color: var(--drc-fail); border-color: rgba(229, 72, 77, .4); background: var(--drc-fail-surface); } +.badge--admin { color: var(--copper-glow); border-color: var(--copper-muted); } + +dl.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 18px; margin: 0; font-size: 14px; } +dl.kv dt { color: var(--text-low); } +dl.kv dd { margin: 0; color: var(--text-high); text-align: right; font-variant-numeric: tabular-nums; } +.price-big { font-size: 36px; font-weight: 700; color: var(--text-pure); letter-spacing: -.03em; font-variant-numeric: tabular-nums; line-height: 1.1; } +.price-big small { font-size: 14px; color: var(--text-low); font-weight: 500; margin-left: 6px; letter-spacing: 0; } +.price-lines { list-style: none; margin: 14px 0 0; padding: 0; font-size: 13.5px; color: var(--text-med); } +.price-lines li { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--surface-stroke-soft); } +.price-lines li span:last-child { font-family: var(--mono); color: var(--text-high); } + +/* ── stat tiles ─────────────────────────────────────────── */ +.stat { padding: 20px; } +.stat__value { font-size: 30px; font-weight: 700; color: var(--text-pure); letter-spacing: -.03em; font-variant-numeric: tabular-nums; } +.stat__label { font-size: 13px; color: var(--text-low); } + +/* ── app shell (portal/admin) ───────────────────────────── */ +.app { display: grid; grid-template-columns: 248px minmax(0, 1fr); min-height: calc(100vh - 64px); flex: 1; } +.app__side { border-right: 1px solid var(--surface-stroke-soft); background: #0a0b0d; } +.side { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 2px; padding: 20px 14px; height: calc(100vh - 64px); overflow-y: auto; } +.side a { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: var(--radius-sm); color: var(--text-med); font-size: 14.5px; } +.side a:hover { color: var(--text-pure); background: var(--surface-elevated); text-decoration: none; } +.side a.is-active { color: var(--text-pure); background: var(--surface-elevated); box-shadow: inset 2px 0 0 var(--copper-primary); } +.side__icon { width: 18px; text-align: center; color: var(--copper-glow); font-family: var(--mono); } +.side__label { font-family: var(--mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-low); margin: 18px 12px 6px; } +.side__label:first-child { margin-top: 0; } +.side__foot { margin-top: auto; border-top: 1px solid var(--surface-stroke-soft); padding: 14px 12px 0; } +.side__user strong { display: block; font-size: 14px; color: var(--text-pure); } +.side__user span { display: block; font-size: 12.5px; color: var(--text-low); overflow: hidden; text-overflow: ellipsis; } +.app__main { padding: 32px 36px 64px; min-width: 0; } +.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 26px; flex-wrap: wrap; } +.page-head h1 { font-size: 30px; margin: 0; } +.page-head p { margin: 6px 0 0; color: var(--text-med); } +@media (max-width: 960px) { + .app { grid-template-columns: 1fr; } + .app__side { border-right: 0; border-bottom: 1px solid var(--surface-stroke-soft); } + .side { position: static; height: auto; flex-direction: row; flex-wrap: wrap; padding: 10px 14px; } + .side__label, .side__foot { display: none; } + .app__main { padding: 22px 18px 48px; } +} + +/* ── order detail ───────────────────────────────────────── */ +.steps { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin: 0 0 22px; } +.steps span { position: relative; padding: 10px 12px 10px 30px; font-size: 13px; border-radius: var(--radius-sm); border: 1px solid var(--surface-stroke); color: var(--text-low); background: var(--surface-elevated); } +.steps span::before { content: ''; position: absolute; left: 12px; top: 50%; width: 9px; height: 9px; margin-top: -4.5px; border-radius: 50%; border: 1.5px solid currentColor; } +.steps span.done { color: var(--copper-glow); border-color: var(--copper-muted); } +.steps span.done::before { background: currentColor; } +.steps span.now { color: var(--text-pure); border-color: var(--copper-primary); background: rgba(223, 130, 68, .12); } +.steps span.now::before { background: var(--copper-primary); border-color: var(--copper-primary); box-shadow: 0 0 10px var(--copper-primary); } +@media (max-width: 760px) { .steps { grid-template-columns: 1fr 1fr; } } +.thread { display: flex; flex-direction: column; gap: 12px; max-height: 480px; overflow-y: auto; padding: 4px 2px; } +.msg { max-width: 82%; padding: 11px 15px; border-radius: 14px; font-size: 14.5px; white-space: pre-wrap; word-wrap: break-word; } +.msg--me { align-self: flex-end; background: rgba(223, 130, 68, .12); border: 1px solid var(--copper-muted); border-bottom-right-radius: 4px; } +.msg--other { align-self: flex-start; background: var(--surface-overlay); border: 1px solid var(--surface-stroke); border-bottom-left-radius: 4px; } +.msg__meta { display: block; font-size: 11.5px; color: var(--text-low); margin-top: 5px; } +.timeline { list-style: none; padding: 0; margin: 0; font-size: 13.5px; } +.timeline li { position: relative; padding: 0 0 14px 20px; border-left: 1px solid var(--surface-stroke); color: var(--text-high); } +.timeline li:last-child { padding-bottom: 0; } +.timeline li::before { content: ''; position: absolute; left: -5px; top: 6px; width: 9px; height: 9px; border-radius: 50%; background: var(--surface-base); border: 2px solid var(--copper-primary); } +.timeline time { color: var(--text-low); font-size: 12px; display: block; font-family: var(--mono); } +.empty { text-align: center; padding: 56px 20px; color: var(--text-med); } +.empty h3 { color: var(--text-pure); } +.pay-methods { display: grid; gap: 8px; margin-top: 14px; } +.pay-method { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--surface-stroke); border-radius: var(--radius-sm); cursor: pointer; background: var(--surface-sunken); font-size: 14.5px; } +.pay-method:has(input:checked) { border-color: var(--copper-primary); background: rgba(223, 130, 68, .07); } +.pay-method input { accent-color: var(--copper-primary); } +.upload-box { margin-top: 16px; border-top: 1px solid var(--surface-stroke-soft); padding-top: 16px; } + +/* ── toast / modal ──────────────────────────────────────── */ +.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--surface-overlay); border: 1px solid var(--surface-stroke); color: var(--text-pure); padding: 11px 18px; border-radius: var(--radius); z-index: 200; box-shadow: 0 16px 40px rgba(0, 0, 0, .5); font-size: 14px; animation: toast-in .2s var(--ease); } +.toast.is-error { border-color: var(--drc-fail); color: #ffb3b5; } +@keyframes toast-in { from { opacity: 0; transform: translate(-50%, 8px); } } +.modal { position: fixed; inset: 0; background: rgba(0, 0, 0, .78); backdrop-filter: blur(4px); z-index: 90; display: grid; place-items: center; padding: 18px; } +.modal__box { width: min(1320px, 100%); height: min(880px, 100%); background: var(--surface-elevated); border: 1px solid var(--surface-stroke); border-radius: 16px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 40px 120px rgba(0, 0, 0, .7); } +.modal__head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--surface-stroke); } +.modal__head .name { flex: 1; font-family: var(--mono); font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.modal__body { flex: 1; position: relative; min-height: 0; } + +/* ── 3D viewer ──────────────────────────────────────────── */ +.qx-viewer { position: relative; width: 100%; height: 100%; min-height: 320px; background: #0b0d10; border-radius: var(--radius); overflow: hidden; } +.qx-viewer:fullscreen { border-radius: 0; } +.qx-viewer__canvas { position: absolute; inset: 0; } +.qx-viewer__canvas canvas { display: block; width: 100%; height: 100%; touch-action: none; outline: none; } +.qx-viewer.is-measuring canvas, .qx-viewer.is-inspecting canvas { cursor: crosshair; } +.qx-viewer__toolbar { position: absolute; top: 12px; left: 12px; right: 12px; display: flex; gap: 6px; flex-wrap: wrap; pointer-events: none; z-index: 2; } +.qx-viewer__group { display: flex; pointer-events: auto; background: rgba(12, 14, 18, .86); border: 1px solid var(--surface-stroke); border-radius: 8px; overflow: hidden; backdrop-filter: blur(6px); } +.qx-viewer__btn { padding: 6px 11px; font: inherit; font-size: 12.5px; cursor: pointer; background: transparent; color: var(--text-med); border: 0; border-right: 1px solid var(--surface-stroke-soft); } +.qx-viewer__btn:last-child { border-right: 0; } +.qx-viewer__btn:hover { color: var(--text-pure); background: rgba(255, 255, 255, .04); } +.qx-viewer__btn.is-on { color: #160b03; background: var(--copper-primary); } +.qx-viewer__overlay { position: absolute; bottom: 12px; left: 12px; pointer-events: none; z-index: 2; } +.qx-viewer__overlay dl { display: grid; grid-template-columns: auto auto; gap: 2px 14px; margin: 0; padding: 10px 14px; background: rgba(8, 9, 10, .82); border: 1px solid var(--surface-stroke); border-radius: 10px; font-size: 12px; backdrop-filter: blur(6px); } +.qx-viewer__overlay dt { color: var(--text-low); } +.qx-viewer__overlay dd { margin: 0; color: var(--text-high); font-family: var(--mono); text-align: right; } +.qx-viewer__panel { position: absolute; top: 54px; right: 12px; width: 236px; max-height: calc(100% - 70px); overflow-y: auto; background: rgba(14, 16, 20, .94); border: 1px solid var(--surface-stroke); border-radius: 10px; padding: 12px 14px; font-size: 13px; z-index: 3; backdrop-filter: blur(8px); } +.qx-viewer__panel h4 { font-size: 11px; margin: 4px 0 8px; color: var(--text-low); text-transform: uppercase; letter-spacing: .1em; font-family: var(--mono); } +.qx-viewer__panel h4:not(:first-child) { margin-top: 14px; } +.qx-viewer__row { display: flex; align-items: center; gap: 9px; padding: 4px 0; cursor: pointer; } +.qx-viewer__row span { font-family: var(--mono); font-size: 12.5px; color: var(--text-high); } +.qx-viewer__swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; } +.qx-viewer__row--slider { flex-direction: column; align-items: stretch; gap: 4px; margin-top: 8px; cursor: default; } +.qx-viewer__row--slider span { font-family: var(--font); color: var(--text-med); font-size: 12.5px; } +.qx-viewer__row input[type=checkbox] { accent-color: var(--copper-primary); } +.qx-viewer__status { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(8, 9, 10, .88); border: 1px solid var(--surface-stroke); padding: 12px 18px; border-radius: 10px; font-size: 13px; color: var(--signal-cyan); max-width: 80%; text-align: center; font-family: var(--mono); z-index: 4; } +.qx-viewer__status.is-error { color: #ffb3b5; border-color: var(--drc-fail); font-family: var(--font); } +.qx-viewer__tip { position: absolute; z-index: 5; pointer-events: none; background: rgba(8, 9, 10, .92); border: 1px solid var(--surface-stroke); border-radius: 8px; padding: 7px 10px; font-size: 12px; color: var(--text-high); white-space: nowrap; transform: translate(14px, 14px); } +.qx-viewer__tip b { color: var(--text-pure); font-family: var(--mono); } +.qx-viewer__tip i { color: var(--signal-cyan); font-style: normal; font-family: var(--mono); } +.qx-viewer__inspect { position: absolute; right: 12px; bottom: 12px; z-index: 3; width: 270px; background: rgba(14, 16, 20, .95); border: 1px solid rgba(44, 229, 229, .35); border-radius: 10px; padding: 12px 14px; font-size: 13px; } +.qx-viewer__inspect h5 { margin: 0 0 8px; font-size: 11px; font-family: var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--signal-cyan); display: flex; justify-content: space-between; } +.qx-viewer__inspect h5 button { background: none; border: 0; color: var(--text-low); cursor: pointer; font-size: 14px; padding: 0; } +.qx-viewer__inspect dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; margin: 0; } +.qx-viewer__inspect dt { color: var(--text-low); } +.qx-viewer__inspect dd { margin: 0; font-family: var(--mono); text-align: right; color: var(--text-high); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.qx-viewer__hint { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); z-index: 2; font-size: 12px; color: var(--text-med); background: rgba(8, 9, 10, .78); border: 1px solid var(--surface-stroke); padding: 5px 12px; border-radius: 999px; pointer-events: none; white-space: nowrap; } +.viewer-box { height: 460px; } +.viewer-page { position: fixed; inset: 64px 0 0 0; } +.viewer-page .qx-viewer { border-radius: 0; } +@media (max-width: 640px) { .qx-viewer__overlay { display: none; } .qx-viewer__panel { width: 200px; } .qx-viewer__inspect { width: auto; left: 12px; } } + +/* studio page */ +.studio { position: relative; height: calc(100vh - 64px); min-height: 520px; } +.studio__drop { position: absolute; inset: 0; } +.studio__drop.is-drag::after { content: 'Drop to open'; position: absolute; inset: 14px; border: 2px dashed var(--copper-glow); border-radius: 14px; display: grid; place-items: center; font-size: 22px; color: var(--copper-glow); background: rgba(8, 9, 10, .7); z-index: 20; } +.studio__bar { position: absolute; z-index: 6; top: 60px; left: 12px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + +/* ── footer ─────────────────────────────────────────────── */ +.site-footer { border-top: 1px solid var(--surface-stroke-soft); padding: 64px 0 28px; background: #070809; } +.site-footer__grid { display: grid; grid-template-columns: 1.6fr repeat(4, 1fr); gap: 32px; } +.site-footer__brand p { color: var(--text-low); font-size: 14px; max-width: 300px; margin-top: 14px; } +.site-footer h4 { font-size: 11.5px; font-family: var(--mono); text-transform: uppercase; letter-spacing: .1em; color: var(--text-low); margin-bottom: 14px; } +.site-footer__grid a:not(.brand) { display: block; color: var(--text-med); font-size: 14px; padding: 4px 0; } +.site-footer__grid a:not(.brand):hover { color: var(--text-pure); text-decoration: none; } +.site-footer__base { display: flex; gap: 20px; flex-wrap: wrap; justify-content: space-between; margin-top: 48px; padding-top: 22px; border-top: 1px solid var(--surface-stroke-soft); font-size: 13px; color: var(--text-low); } +@media (max-width: 960px) { .site-footer__grid { grid-template-columns: 1fr 1fr; } .site-footer__brand { grid-column: 1 / -1; } } + +/* ── receipt (print friendly) ───────────────────────────── */ +.receipt { max-width: 780px; background: #fff; color: #111; border-radius: var(--radius); padding: 48px; } +.receipt h1, .receipt h2, .receipt h3 { color: #111; } +.receipt .muted, .receipt .low { color: #555; } +.receipt table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 14px; } +.receipt th, .receipt td { text-align: left; padding: 9px 0; border-bottom: 1px solid #e3e3e3; } +.receipt .num { text-align: right; font-family: var(--mono); } +.receipt .total td { font-weight: 700; border-top: 2px solid #111; border-bottom: 0; font-size: 16px; } +.receipt .paid-stamp { display: inline-block; border: 2px solid #1a8f4f; color: #1a8f4f; border-radius: 6px; padding: 3px 12px; font-weight: 700; letter-spacing: .1em; transform: rotate(-4deg); } +.receipt .receipt__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 28px 0; font-size: 14px; } +@media print { + .site-header, .app__side, .no-print, .skip { display: none !important; } + body, .app, .app__main { background: #fff; padding: 0; display: block; } + .receipt { padding: 0; max-width: none; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } } diff --git a/pcb-portal/public/favicon.svg b/pcb-portal/public/favicon.svg new file mode 100644 index 0000000..08fbec5 --- /dev/null +++ b/pcb-portal/public/favicon.svg @@ -0,0 +1 @@ +QX diff --git a/pcb-portal/public/js/app.js b/pcb-portal/public/js/app.js new file mode 100644 index 0000000..2a365f6 --- /dev/null +++ b/pcb-portal/public/js/app.js @@ -0,0 +1,159 @@ +// Shared client helpers: API calls, formatting, safe DOM building. + +export async function api(path, { method = 'GET', body, form } = {}) { + const headers = { 'X-Qodex-Request': '1' }; + let payload; + if (form) payload = form; + else if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + payload = JSON.stringify(body); + } + const res = await fetch(`/api${path}`, { method, headers, body: payload, credentials: 'same-origin' }); + let data = null; + try { + data = await res.json(); + } catch { + /* empty body */ + } + if (!res.ok) { + const err = new Error((data && data.error) || `Request failed (${res.status})`); + err.status = res.status; + throw err; + } + return data; +} + +/** Multipart upload with progress (fetch has no upload progress events). */ +export function uploadForm(path, form, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `/api${path}`); + xhr.setRequestHeader('X-Qodex-Request', '1'); + xhr.upload.onprogress = (e) => e.lengthComputable && onProgress?.(e.loaded / e.total); + xhr.onload = () => { + let data = null; + try { + data = JSON.parse(xhr.responseText); + } catch { + /* ignore */ + } + if (xhr.status >= 200 && xhr.status < 300) resolve(data); + else reject(new Error((data && data.error) || `Upload failed (${xhr.status})`)); + }; + xhr.onerror = () => reject(new Error('Connection lost during upload.')); + xhr.send(form); + }); +} + +/** Tiny hyperscript: el('a', { href, class: 'x', onclick }, 'text', child). Text is never parsed as HTML. */ +export function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v === undefined || v === null || v === false) continue; + if (k === 'class') node.className = v; + else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v); + else if (k === 'dataset') Object.assign(node.dataset, v); + else if (v === true) node.setAttribute(k, ''); + else node.setAttribute(k, String(v)); + } + for (const c of children.flat(Infinity)) { + if (c === null || c === undefined || c === false) continue; + node.append(c instanceof Node ? c : document.createTextNode(String(c))); + } + return node; +} + +export const $ = (sel, root = document) => root.querySelector(sel); + +let configPromise; +export function getConfig() { + configPromise ??= api('/config'); + return configPromise; +} + +/** The signed-in user embedded by the server (no extra request). */ +export function currentUser() { + try { + return JSON.parse(document.getElementById('qx-user')?.textContent || 'null'); + } catch { + return null; + } +} + +const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); + +/** Format integer cents as US dollars. */ +export function money(cents) { + if (cents === null || cents === undefined) return '—'; + return usd.format(cents / 100); +} + +/** Format a dollar amount (quote breakdown lines). */ +export function dollars(amount) { + return usd.format(amount); +} + +export function fmtDate(sqlDate, withTime = true) { + if (!sqlDate) return '—'; + const d = new Date(sqlDate.includes('T') ? sqlDate : `${sqlDate.replace(' ', 'T')}Z`); + return new Intl.DateTimeFormat('en-US', withTime ? { dateStyle: 'medium', timeStyle: 'short' } : { dateStyle: 'medium' }).format(d); +} + +export function fmtSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 ** 2).toFixed(1)} MB`; +} + +export function badge(status, label) { + return el('span', { class: `badge badge--${status}` }, label || status); +} + +export function toast(message, isError = false) { + const t = el('div', { class: `toast${isError ? ' is-error' : ''}`, role: 'status' }, message); + document.body.append(t); + setTimeout(() => t.remove(), 4200); +} + +export function qs(name) { + return new URLSearchParams(location.search).get(name); +} + +export function fileExt(name) { + return String(name).toLowerCase().split('.').pop(); +} + +const VIEWABLE = new Set(['kicad_pcb', 'glb', 'gltf', 'stl', 'obj', 'wrl']); +export function extBadge(name) { + const ext = fileExt(name); + return el('span', { class: `file-ext${VIEWABLE.has(ext) ? ' file-ext--3d' : ''}` }, ext.slice(0, 9)); +} + +/** Open a full-screen modal with the 3D viewer for an order file. */ +export async function openViewerModal(file, look = {}) { + const { createViewer } = await import('./viewer/viewer.js'); + const body = el('div', { class: 'modal__body' }); + let viewer = null; + const close = () => { + viewer?.dispose(); + modal.remove(); + document.removeEventListener('keydown', onKey); + }; + const onKey = (e) => e.key === 'Escape' && !document.querySelector('.qx-viewer.is-measuring, .qx-viewer.is-inspecting') && close(); + const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': `3D view of ${file.name}` }, + el('div', { class: 'modal__box' }, + el('div', { class: 'modal__head' }, + extBadge(file.name), + el('span', { class: 'name' }, file.name), + el('a', { class: 'btn btn--sm', href: `/portal/files/${file.id}/view`, target: '_blank', rel: 'noopener' }, 'Open in new tab'), + el('a', { class: 'btn btn--sm', href: `/api/files/${file.id}` }, 'Download'), + el('button', { class: 'btn btn--sm', type: 'button', onclick: close, 'aria-label': 'Close' }, 'Close'), + ), + body, + ), + ); + document.body.append(modal); + document.addEventListener('keydown', onKey); + viewer = createViewer(body, { board: look }); + viewer.loadUrl(`/api/files/${file.id}`, file.name).catch(() => {}); +} diff --git a/pcb-portal/public/js/chrome.js b/pcb-portal/public/js/chrome.js new file mode 100644 index 0000000..48a6567 --- /dev/null +++ b/pcb-portal/public/js/chrome.js @@ -0,0 +1,16 @@ +// Site chrome behaviour: mobile navigation and sign-out. Loaded on every page. +import { api } from './app.js'; + +const toggle = document.querySelector('[data-nav-toggle]'); +const nav = document.querySelector('[data-nav]'); +toggle?.addEventListener('click', () => { + const open = nav.classList.toggle('is-open'); + toggle.setAttribute('aria-expanded', String(open)); +}); + +for (const btn of document.querySelectorAll('[data-logout]')) { + btn.addEventListener('click', async () => { + await api('/auth/logout', { method: 'POST' }).catch(() => {}); + location.href = '/'; + }); +} diff --git a/pcb-portal/public/js/order-view.js b/pcb-portal/public/js/order-view.js new file mode 100644 index 0000000..4218ba4 --- /dev/null +++ b/pcb-portal/public/js/order-view.js @@ -0,0 +1,278 @@ +// Order detail view shared by the customer portal and the admin panel. +import { api, badge, dollars, el, extBadge, fmtDate, fmtSize, money, openViewerModal, toast, uploadForm } from './app.js'; + +const FLOW = ['Placed', 'Quote & payment', 'Engineering', 'Delivered', 'Completed']; +const FLOW_INDEX = { quote_pending: 1, awaiting_payment: 1, paid: 2, in_progress: 2, delivered: 3, completed: 4 }; + +const EVENT_TEXT = { + created: 'Order placed', + status: (d, labels) => `Status: ${labels[d.from] || d.from} → ${labels[d.to] || d.to}${d.manual ? ' (payment recorded manually)' : ''}`, + price: (d) => `Price set to ${money(d.to)}`, + files_added: (d) => `${d.count} file${d.count === 1 ? '' : 's'} added by ${d.source === 'admin' ? 'QodeX' : 'customer'}`, + file_deleted: (d) => `File "${d.name}" removed`, + payment_started: (d) => `Checkout started (${providerName(d.provider)})`, + payment_succeeded: (d) => `Payment confirmed — ${providerName(d.provider)} ref ${d.refId || '—'}`, + payment_failed: (d) => `Payment not completed (${providerName(d.provider)})`, + payment_attention: (d) => d.message, + parse_warning: (d) => d.message, +}; + +export function providerName(p) { + return { stripe: 'Stripe', paypal: 'PayPal', mock: 'Test checkout' }[p] || p; +} + +export function renderOrder(root, detail, { admin = false, cfg, reload }) { + const { order, files, messages, payments, events } = detail; + const labels = cfg.statusLabels; + const look = { maskColor: order.specs.maskColor, finish: order.specs.finish, silkColor: order.specs.silkColor }; + root.replaceChildren(); + + // ── header ── + root.append(el('div', { class: 'page-head' }, + el('div', {}, + el('div', { class: 'low mono' }, order.code), + el('h1', {}, order.title), + el('div', { class: 'row', style: 'margin-top:8px' }, + badge(order.status, order.statusLabel), + el('span', { class: 'low' }, `Placed ${fmtDate(order.createdAt)}`), + admin && order.customer ? el('span', { class: 'low' }, `· ${order.customer.name} · ${order.customer.email}${order.customer.company ? ` · ${order.customer.company}` : ''}`) : null), + ), + el('a', { class: 'btn btn--sm', href: admin ? '/admin' : '/portal' }, admin ? '← Order queue' : '← All orders'), + )); + + const flag = new URLSearchParams(location.search).get('payment'); + if (flag && !admin) { + const ok = flag === 'success'; + root.append(el('div', { class: ok ? 'form-ok' : flag === 'cancelled' ? 'form-warn' : 'form-error', style: 'margin-bottom:18px' }, + ok ? 'Payment confirmed. Your board is in the engineering queue — we will post updates here.' + : flag === 'cancelled' ? 'Checkout was cancelled. You can try again whenever you are ready.' + : 'The payment could not be confirmed. If you were charged, contact us and we will resolve it.')); + history.replaceState(null, '', location.pathname); + } + + if (order.status !== 'cancelled') { + const idx = FLOW_INDEX[order.status] ?? 0; + root.append(el('div', { class: 'steps' }, FLOW.map((label, i) => el('span', { class: i < idx ? 'done' : i === idx ? 'now' : '' }, label)))); + } + + const main = el('div', { class: 'stack-cards' }); + const side = el('aside', { class: 'stack-cards' }); + root.append(el('div', { class: 'split' }, main, side)); + + // ── files ── + const fileTable = (list, emptyText) => + list.length + ? el('div', { class: 'table-wrap' }, el('table', { class: 'table' }, el('tbody', {}, list.map((f) => el('tr', {}, + el('td', { style: 'width:1%' }, extBadge(f.name)), + el('td', { class: 'name-cell' }, f.name, f.note ? el('div', { class: 'low', style: 'font-family:var(--font)' }, f.note) : null), + el('td', { class: 'low nowrap' }, fmtSize(f.size)), + el('td', { class: 'low nowrap' }, fmtDate(f.createdAt)), + el('td', {}, el('div', { class: 'row row--end', style: 'flex-wrap:nowrap' }, + f.viewerKind ? el('button', { type: 'button', class: 'btn btn--primary btn--sm', onclick: () => openViewerModal(f, look) }, 'View 3D') : null, + el('a', { class: 'btn btn--sm', href: `/api/files/${f.id}` }, 'Download'), + admin ? el('button', { type: 'button', class: 'btn btn--sm btn--danger', 'aria-label': `Delete ${f.name}`, onclick: () => deleteFile(f) }, 'Delete') : null, + )), + ))))) + : el('p', { class: 'muted', style: 'margin:0' }, emptyText); + + async function deleteFile(f) { + if (!confirm(`Delete "${f.name}"? This cannot be undone.`)) return; + try { + await api(`/admin/files/${f.id}`, { method: 'DELETE' }); + reload(); + } catch (err) { + toast(err.message, true); + } + } + + main.append(el('section', { class: 'card' }, + el('div', { class: 'card__title' }, el('h2', {}, 'Deliverables'), el('span', { class: 'low' }, 'Routed board, 3D model, fab outputs, reports')), + fileTable(files.filter((f) => f.source === 'admin'), admin ? 'Nothing sent to the customer yet.' : 'When your board is ready, the routed files appear here — open any of them in 3D.'), + admin ? uploadBox(true) : null, + )); + main.append(el('section', { class: 'card' }, + el('div', { class: 'card__title' }, el('h2', {}, admin ? 'Customer files' : 'Your files')), + fileTable(files.filter((f) => f.source === 'customer'), 'No files.'), + !admin && !['cancelled', 'completed'].includes(order.status) ? uploadBox(false) : null, + )); + + function uploadBox(isAdmin) { + const input = el('input', { type: 'file', multiple: true, 'aria-label': 'Choose files' }); + const note = el('input', { type: 'text', placeholder: isAdmin ? 'Note for the customer (optional), e.g. "Final routed board, rev B"' : 'Note (optional)', maxlength: 500 }); + const deliver = el('input', { type: 'checkbox' }); + const status = el('span', { class: 'low' }); + const btn = el('button', { type: 'submit', class: 'btn btn--primary btn--sm' }, isAdmin ? 'Send to customer' : 'Add files'); + const form = el('form', { class: 'stack upload-box' }, + input, + note, + isAdmin && ['paid', 'in_progress'].includes(order.status) ? el('label', { class: 'check' }, deliver, el('span', {}, 'Mark the order as delivered')) : null, + el('div', { class: 'row' }, btn, status), + ); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!input.files.length) return toast('Choose at least one file.', true); + const fd = new FormData(); + for (const f of input.files) fd.append('files', f, f.name); + fd.append('note', note.value); + if (deliver.checked) fd.append('markDelivered', 'true'); + btn.disabled = true; + try { + await uploadForm(isAdmin ? `/admin/orders/${order.code}/files` : `/orders/${order.code}/files`, fd, (p) => { status.textContent = `Uploading… ${Math.round(p * 100)}%`; }); + toast('Files uploaded.'); + reload(); + } catch (err) { + toast(err.message, true); + btn.disabled = false; + status.textContent = ''; + } + }); + return form; + } + + // ── messages ── + const thread = el('div', { class: 'thread' }, messages.length + ? messages.map((m) => el('div', { class: `msg ${m.isAdmin === admin ? 'msg--me' : 'msg--other'}` }, m.body, + el('span', { class: 'msg__meta' }, `${m.isAdmin ? 'QodeX engineer · ' : ''}${m.author} · ${fmtDate(m.createdAt)}`))) + : el('p', { class: 'muted', style: 'margin:0' }, admin ? 'No messages yet.' : 'Questions or extra requirements? Message your engineer directly.')); + const textarea = el('textarea', { placeholder: 'Write a message…', maxlength: 5000, required: true, 'aria-label': 'Message' }); + const sendBtn = el('button', { type: 'submit', class: 'btn btn--primary btn--sm' }, 'Send'); + const msgForm = el('form', { class: 'stack', style: 'margin-top:14px' }, textarea, el('div', { class: 'row' }, sendBtn, el('span', { class: 'low' }, 'Ctrl/⌘ + Enter to send'))); + textarea.addEventListener('keydown', (e) => (e.ctrlKey || e.metaKey) && e.key === 'Enter' && msgForm.requestSubmit()); + msgForm.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!textarea.value.trim()) return; + sendBtn.disabled = true; + try { + await api(`/orders/${order.code}/messages`, { method: 'POST', body: { body: textarea.value } }); + reload(); + } catch (err) { + toast(err.message, true); + sendBtn.disabled = false; + } + }); + main.append(el('section', { class: 'card' }, el('div', { class: 'card__title' }, el('h2', {}, admin ? 'Conversation with customer' : 'Your engineer')), thread, msgForm)); + requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; }); + + // ── price / payment ── + const payCard = el('section', { class: 'card', id: 'pay' }, + el('span', { class: 'eyebrow' }, 'Order total'), + el('div', { class: 'price-big' }, order.price ? money(order.price) : 'Awaiting quote', order.price ? el('small', {}, 'USD') : null), + order.priceBreakdown && !order.priceIsManual + ? el('ul', { class: 'price-lines' }, order.priceBreakdown.map((l) => el('li', {}, el('span', {}, l.label), el('span', {}, dollars(l.amount))))) + : order.priceIsManual ? el('p', { class: 'low', style: 'margin:10px 0 0' }, 'Quoted by your engineer.') : null, + ); + side.append(payCard); + + if (!admin) { + if (order.status === 'awaiting_payment') { + const methods = cfg.paymentMethods; + const radios = el('div', { class: 'pay-methods', role: 'radiogroup', 'aria-label': 'Payment method' }, + methods.map((m, i) => el('label', { class: 'pay-method' }, el('input', { type: 'radio', name: 'method', value: m.id, checked: i === 0 }), el('span', {}, m.label)))); + const payBtn = el('button', { type: 'button', class: 'btn btn--primary btn--lg btn--block', style: 'margin-top:14px' }, `Pay ${money(order.price)}`); + payBtn.addEventListener('click', async () => { + const method = radios.querySelector('input:checked')?.value; + payBtn.disabled = true; + payBtn.textContent = 'Redirecting to secure checkout…'; + try { + const { redirectUrl } = await api(`/orders/${order.code}/pay`, { method: 'POST', body: { method } }); + location.href = redirectUrl; + } catch (err) { + toast(err.message, true); + payBtn.disabled = false; + payBtn.textContent = `Pay ${money(order.price)}`; + } + }); + payCard.append(radios, payBtn, el('p', { class: 'low', style: 'margin:10px 0 0' }, 'You will be redirected to the provider’s secure page. We never see your card details.')); + if (new URLSearchParams(location.search).get('pay') === '1') requestAnimationFrame(() => payCard.scrollIntoView({ behavior: 'smooth', block: 'center' })); + } else if (order.status === 'quote_pending') { + payCard.append(el('p', { class: 'muted', style: 'margin:12px 0 0' }, 'An engineer is reviewing your board and will set the price shortly. You will be able to pay here.')); + } else if (order.paidAt) { + const paid = payments.find((p) => p.status === 'paid'); + payCard.append(el('div', { class: 'form-ok', style: 'margin-top:14px' }, `Paid ${fmtDate(order.paidAt)}`), + paid ? el('a', { class: 'btn btn--sm', style: 'margin-top:10px', href: `/portal/receipts/${paid.id}` }, 'View receipt') : null); + } + if (['quote_pending', 'awaiting_payment'].includes(order.status)) { + payCard.append(el('button', { + type: 'button', class: 'btn btn--ghost btn--sm', style: 'margin-top:12px', + onclick: async () => { + if (!confirm('Cancel this order?')) return; + try { await api(`/orders/${order.code}/cancel`, { method: 'POST' }); reload(); } catch (err) { toast(err.message, true); } + }, + }, 'Cancel order')); + } + if (order.status === 'delivered') { + side.append(el('section', { class: 'card' }, + el('h3', {}, 'Happy with the result?'), + el('p', { class: 'muted' }, 'Confirm once you have reviewed the deliverables. You can still download them afterwards.'), + el('button', { + type: 'button', class: 'btn btn--primary', onclick: async () => { + try { await api(`/orders/${order.code}/confirm`, { method: 'POST' }); reload(); } catch (err) { toast(err.message, true); } + }, + }, 'Confirm & close order'))); + } + } + + if (admin) side.append(adminControls(order, cfg, reload)); + + // ── specification ── + const s = order.specs; + const o = cfg.options; + const fab = ['routing_fab', 'fab_only'].includes(s.service) + ? [['Quantity', s.quantity], ['Thickness', `${s.thickness} mm`], ['Copper', `${s.copperOz} oz`], ['Finish', o.finish[s.finish]], ['Solder mask', o.maskColor[s.maskColor]], ['Silkscreen', o.silkColor[s.silkColor]]] + : []; + side.append(el('section', { class: 'card' }, el('div', { class: 'card__title' }, el('h3', {}, 'Specification')), + el('dl', { class: 'kv' }, [ + ['Service', cfg.services[s.service]], ['Layers', s.layers], ['Size', `${s.widthMm} × ${s.heightMm} mm`], ...fab, + ['Target fab', o.fabHouse[s.fabHouse]], ['Turnaround', o.turnaround[s.turnaround]], ['Controlled impedance', s.impedanceControl ? 'Yes' : 'No'], + ...(order.boardMeta ? [['Nets / pads', `${order.boardMeta.nets} / ${order.boardMeta.pads}`], ['Footprints', order.boardMeta.footprints], ['KiCad', order.boardMeta.kicadVersion]] : []), + ].flatMap(([k, v]) => [el('dt', {}, k), el('dd', {}, String(v ?? '—'))])), + order.notes ? el('div', { style: 'margin-top:16px' }, el('div', { class: 'low' }, 'Notes'), el('p', { style: 'white-space:pre-wrap;font-size:14px;margin:4px 0 0' }, order.notes)) : null, + )); + + if (payments.length) { + side.append(el('section', { class: 'card' }, el('div', { class: 'card__title' }, el('h3', {}, 'Transactions')), + el('ul', { class: 'price-lines' }, payments.map((p) => el('li', {}, + el('span', {}, `${providerName(p.provider)} · `, badge(p.status === 'paid' ? 'completed' : p.status === 'pending' ? 'pending' : 'failed', p.status), p.refId ? el('span', { class: 'low mono' }, ` ${p.refId}`) : null), + el('span', {}, money(p.amount))))))); + } + + side.append(el('section', { class: 'card' }, el('div', { class: 'card__title' }, el('h3', {}, 'Activity')), + el('ul', { class: 'timeline' }, events.map((e) => { + const t = EVENT_TEXT[e.type]; + const text = typeof t === 'function' ? t(e.data || {}, labels) : t || e.type; + return el('li', {}, el('time', {}, fmtDate(e.createdAt)), text); + })))); +} + +function adminControls(order, cfg, reload) { + const statusSel = el('select', { 'aria-label': 'Status' }, Object.entries(cfg.statusLabels).map(([v, l]) => el('option', { value: v, selected: v === order.status }, l))); + const locked = ['paid', 'in_progress', 'delivered', 'completed'].includes(order.status); + const priceIn = el('input', { type: 'number', min: 0, step: '0.01', value: order.price ? (order.price / 100).toFixed(2) : '', disabled: locked, placeholder: 'e.g. 249.00' }); + const noteIn = el('textarea', { placeholder: 'Internal note — never shown to the customer' }, order.adminNote || ''); + const btn = el('button', { type: 'submit', class: 'btn btn--primary' }, 'Save changes'); + const form = el('form', { class: 'card stack' }, + el('div', { class: 'card__title' }, el('h3', {}, 'Manage order'), el('span', { class: 'badge badge--admin' }, 'Admin')), + el('label', { class: 'field' }, el('span', { class: 'field__label' }, 'Price (USD)'), priceIn, + el('span', { class: 'field__hint' }, locked ? 'Locked — the order is paid.' : 'Setting a price moves "Awaiting quote" to "Awaiting payment". Any open checkout is cancelled.')), + el('label', { class: 'field' }, el('span', { class: 'field__label' }, 'Status'), statusSel, + el('span', { class: 'field__hint' }, 'For payments received off-site (wire transfer, invoice), set the status to "Paid" manually.')), + el('label', { class: 'field' }, el('span', { class: 'field__label' }, 'Internal note'), noteIn), + btn, + ); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const body = { adminNote: noteIn.value }; + if (statusSel.value !== order.status) body.status = statusSel.value; + if (!locked && priceIn.value !== '' && Math.round(Number(priceIn.value) * 100) !== order.price) body.price = Number(priceIn.value); + btn.disabled = true; + try { + await api(`/admin/orders/${order.code}`, { method: 'PATCH', body }); + toast('Saved.'); + reload(); + } catch (err) { + toast(err.message, true); + btn.disabled = false; + } + }); + return form; +} diff --git a/pcb-portal/public/js/pages/admin-customers.js b/pcb-portal/public/js/pages/admin-customers.js new file mode 100644 index 0000000..b017d76 --- /dev/null +++ b/pcb-portal/public/js/pages/admin-customers.js @@ -0,0 +1,15 @@ +import { api, el, fmtDate, money } from '../app.js'; + +const box = document.getElementById('list'); +const { customers } = await api('/admin/customers'); +box.replaceChildren(el('table', { class: 'table' }, + el('thead', {}, el('tr', {}, ['Name', 'Email', 'Company', 'Country', 'Orders', 'Spent', 'Joined'].map((h) => el('th', {}, h)))), + el('tbody', {}, customers.map((c) => el('tr', {}, + el('td', {}, c.name, c.role === 'admin' ? el('span', { class: 'badge badge--admin', style: 'margin-left:8px' }, 'admin') : null), + el('td', { class: 'mono' }, el('a', { href: `mailto:${c.email}` }, c.email)), + el('td', {}, c.company || '—'), + el('td', {}, c.country || '—'), + el('td', { class: 'mono' }, c.orders), + el('td', { class: 'mono' }, money(c.spent)), + el('td', { class: 'low nowrap' }, fmtDate(c.created_at, false)), + ))))); diff --git a/pcb-portal/public/js/pages/admin-inquiries.js b/pcb-portal/public/js/pages/admin-inquiries.js new file mode 100644 index 0000000..3afa94f --- /dev/null +++ b/pcb-portal/public/js/pages/admin-inquiries.js @@ -0,0 +1,25 @@ +import { api, badge, el, fmtDate, getConfig, toast } from '../app.js'; + +const cfg = await getConfig(); +const box = document.getElementById('list'); + +async function load() { + const { inquiries } = await api('/admin/inquiries'); + if (!inquiries.length) return box.replaceChildren(el('div', { class: 'card empty' }, 'No inquiries yet.')); + box.replaceChildren(...inquiries.map((q) => el('div', { class: 'card' }, + el('div', { class: 'card__title' }, + el('div', {}, el('h3', {}, q.name, q.company ? el('span', { class: 'low' }, ` · ${q.company}`) : null), el('div', { class: 'low' }, `${cfg.inquiryTopics[q.topic] || q.topic} · ${fmtDate(q.created_at)}`)), + badge(q.status, q.status === 'new' ? 'New' : 'Handled'), + ), + el('p', { style: 'white-space:pre-wrap' }, q.message), + el('div', { class: 'row' }, + el('a', { class: 'btn btn--primary btn--sm', href: `mailto:${q.email}?subject=${encodeURIComponent(`Re: your message to ${cfg.siteName}`)}` }, `Reply to ${q.email}`), + el('button', { + type: 'button', class: 'btn btn--sm', onclick: async () => { + try { await api(`/admin/inquiries/${q.id}`, { method: 'PATCH', body: { status: q.status === 'new' ? 'handled' : 'new' } }); load(); } catch (err) { toast(err.message, true); } + }, + }, q.status === 'new' ? 'Mark handled' : 'Mark as new'), + ), + ))); +} +load(); diff --git a/pcb-portal/public/js/pages/admin-order.js b/pcb-portal/public/js/pages/admin-order.js new file mode 100644 index 0000000..1653a02 --- /dev/null +++ b/pcb-portal/public/js/pages/admin-order.js @@ -0,0 +1,17 @@ +import { api, getConfig } from '../app.js'; +import { renderOrder } from '../order-view.js'; + +const root = document.getElementById('app'); +const code = decodeURIComponent(location.pathname.split('/').pop()); +const cfg = await getConfig(); + +async function load() { + try { + const detail = await api(`/admin/orders/${encodeURIComponent(code)}`); + document.title = `${detail.order.code} · ${detail.order.title} | QodeX admin`; + renderOrder(root, detail, { admin: true, cfg, reload: load }); + } catch (err) { + root.textContent = err.message; + } +} +load(); diff --git a/pcb-portal/public/js/pages/admin-orders.js b/pcb-portal/public/js/pages/admin-orders.js new file mode 100644 index 0000000..0468cf7 --- /dev/null +++ b/pcb-portal/public/js/pages/admin-orders.js @@ -0,0 +1,45 @@ +import { api, badge, el, fmtDate, getConfig, money } from '../app.js'; + +const cfg = await getConfig(); +const statusSel = document.getElementById('status'); +const q = document.getElementById('q'); +statusSel.append(el('option', { value: 'open' }, 'Open orders'), el('option', { value: '' }, 'All orders'), + ...Object.entries(cfg.statusLabels).map(([v, l]) => el('option', { value: v }, l))); + +async function loadStats() { + const s = await api('/admin/stats'); + const n = (k) => s.byStatus[k] || 0; + const tiles = [ + ['Awaiting quote', n('quote_pending')], + ['To route (paid / in progress)', n('paid') + n('in_progress')], + ['Revenue, last 30 days', money(s.revenue30)], + ['New inquiries', s.newInquiries], + ]; + document.getElementById('stats').replaceChildren(...tiles.map(([label, value]) => el('div', { class: 'card stat' }, el('div', { class: 'stat__value' }, String(value)), el('div', { class: 'stat__label' }, label)))); +} + +async function load() { + const params = new URLSearchParams({ status: statusSel.value, q: q.value.trim() }); + const { orders } = await api(`/admin/orders?${params}`); + const box = document.getElementById('orders'); + if (!orders.length) return box.replaceChildren(el('p', { class: 'empty' }, 'No orders match.')); + box.replaceChildren(el('table', { class: 'table' }, + el('thead', {}, el('tr', {}, ['Order', 'Project', 'Customer', 'Service', 'Layers', 'Total', 'Status', 'Updated'].map((h) => el('th', {}, h)))), + el('tbody', {}, orders.map((o) => el('tr', {}, + el('td', {}, el('a', { class: 'rowlink', href: `/admin/orders/${o.code}` }, o.code)), + el('td', {}, o.title), + el('td', {}, o.customer?.name, el('div', { class: 'low mono' }, o.customer?.email)), + el('td', { class: 'muted' }, cfg.services[o.service]), + el('td', { class: 'mono' }, o.specs.layers), + el('td', { class: 'mono' }, money(o.price)), + el('td', {}, badge(o.status, o.statusLabel)), + el('td', { class: 'low nowrap' }, fmtDate(o.updatedAt)), + ))))); +} + +let t; +q.addEventListener('input', () => { clearTimeout(t); t = setTimeout(load, 250); }); +statusSel.addEventListener('change', load); +loadStats(); +load(); +setInterval(() => { loadStats(); load(); }, 60_000); diff --git a/pcb-portal/public/js/pages/admin-settings.js b/pcb-portal/public/js/pages/admin-settings.js new file mode 100644 index 0000000..f23fca9 --- /dev/null +++ b/pcb-portal/public/js/pages/admin-settings.js @@ -0,0 +1,64 @@ +import { api, el, toast } from '../app.js'; + +const { pricing, defaults } = await api('/admin/settings/pricing'); +const box = document.getElementById('form'); +const SERVICE_NAMES = { routing: 'Routing + DFM polish', routing_fab: 'Routing + fabrication', dfm_review: 'DFM / DRC review', fab_only: 'Fabrication only' }; +const FIELD_NAMES = { base: 'Base ($)', perLayer: 'Per layer ($)', perCm2: 'Per cm² ($)', perNet: 'Per net ($)', perPad: 'Per pad ($)' }; +let state = structuredClone(pricing); + +function num(path, label, hint) { + const get = () => path.reduce((o, k) => o?.[k], state); + const input = el('input', { type: 'number', step: 'any', min: 0, value: get() ?? 0 }); + input.addEventListener('input', () => { + let o = state; + for (const k of path.slice(0, -1)) o = o[k]; + o[path.at(-1)] = input.value === '' ? 0 : Number(input.value); + }); + return el('label', { class: 'field' }, el('span', { class: 'field__label' }, label), input, hint ? el('span', { class: 'field__hint' }, hint) : null); +} + +function render() { + const auto = el('input', { type: 'checkbox', checked: !!state.autoQuote }); + auto.addEventListener('change', () => { state.autoQuote = auto.checked; }); + const save = el('button', { type: 'button', class: 'btn btn--primary' }, 'Save pricing'); + save.addEventListener('click', async () => { + try { + state = (await api('/admin/settings/pricing', { method: 'PUT', body: { pricing: state } })).pricing; + toast('Pricing saved.'); + } catch (err) { + toast(err.message, true); + } + }); + const reset = el('button', { type: 'button', class: 'btn btn--ghost' }, 'Load defaults'); + reset.addEventListener('click', () => { + if (!confirm('Load the default tariffs into the form? Nothing changes until you save.')) return; + state = structuredClone(defaults); + render(); + }); + box.replaceChildren( + el('label', { class: 'check' }, auto, el('span', {}, 'Automatic quotes — customers can pay immediately after placing an order')), + el('div', { class: 'grid grid--2' }, num(['minimum'], 'Minimum order ($)'), num(['roundTo'], 'Round up to ($)', 'e.g. 1 for whole dollars, 5 for $5 steps')), + ...Object.keys(SERVICE_NAMES).flatMap((key) => [ + el('h3', { style: 'margin-top:14px' }, SERVICE_NAMES[key]), + el('div', { class: 'grid grid--3' }, Object.entries(FIELD_NAMES).map(([f, l]) => num(['services', key, f], l))), + ]), + el('h3', { style: 'margin-top:14px' }, 'Fabrication'), + el('div', { class: 'grid grid--3' }, + num(['fab', 'setup'], 'Setup ($)'), + num(['fab', 'perCm2'], 'Per cm² per board ($)', 'Multiplied by the layer factor'), + num(['fab', 'colorExtra'], 'Non-green mask surcharge', 'Fraction, 0.05 = 5 %')), + el('h4', {}, 'Layer factor'), + el('div', { class: 'grid grid--4' }, Object.keys(state.fab.layerFactor).map((l) => num(['fab', 'layerFactor', l], `${l} layer${l === '1' ? '' : 's'}`))), + el('h4', {}, 'Copper weight factor'), + el('div', { class: 'grid grid--3' }, Object.keys(state.fab.copperOzFactor).map((l) => num(['fab', 'copperOzFactor', l], `${l} oz`))), + el('h4', {}, 'Surface finish surcharge (fraction)'), + el('div', { class: 'grid grid--3' }, Object.keys(state.fab.finishExtra).map((l) => num(['fab', 'finishExtra', l], l.toUpperCase().replace('_', ' ')))), + el('h3', { style: 'margin-top:14px' }, 'Multipliers'), + el('div', { class: 'grid grid--3' }, + num(['multipliers', 'express'], 'Express turnaround', 'e.g. 1.5'), + num(['multipliers', 'impedanceControl'], 'Controlled impedance', 'e.g. 1.2'), + num(['multipliers', 'bga'], 'Board with BGA', 'e.g. 1.25')), + el('div', { class: 'row', style: 'margin-top:14px' }, save, reset), + ); +} +render(); diff --git a/pcb-portal/public/js/pages/auth.js b/pcb-portal/public/js/pages/auth.js new file mode 100644 index 0000000..5f9b997 --- /dev/null +++ b/pcb-portal/public/js/pages/auth.js @@ -0,0 +1,24 @@ +import { api, qs } from '../app.js'; + +const form = document.getElementById('form'); +const next = qs('next'); +const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : null; +const alt = document.getElementById('alt'); +if (alt && safeNext) alt.href += `?next=${encodeURIComponent(safeNext)}`; +const errorBox = document.getElementById('error'); +const isRegister = form.dataset.mode === 'register'; + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorBox.hidden = true; + const btn = form.querySelector('button[type=submit]'); + btn.disabled = true; + try { + const { user } = await api(isRegister ? '/auth/register' : '/auth/login', { method: 'POST', body: Object.fromEntries(new FormData(form)) }); + location.href = safeNext || (user.role === 'admin' ? '/admin' : '/portal'); + } catch (err) { + errorBox.textContent = err.message; + errorBox.hidden = false; + btn.disabled = false; + } +}); diff --git a/pcb-portal/public/js/pages/billing.js b/pcb-portal/public/js/pages/billing.js new file mode 100644 index 0000000..7be1f65 --- /dev/null +++ b/pcb-portal/public/js/pages/billing.js @@ -0,0 +1,26 @@ +import { api, el, fmtDate, money } from '../app.js'; +import { providerName } from '../order-view.js'; + +const { payments } = await api('/payments'); +const total = payments.reduce((s, p) => s + p.amount, 0); +const last = payments[0]; +document.getElementById('stats').replaceChildren( + ...[['Payments', payments.length], ['Total paid', money(total)], ['Last payment', last ? fmtDate(last.paidAt, false) : '—']] + .map(([l, v]) => el('div', { class: 'card stat' }, el('div', { class: 'stat__value' }, String(v)), el('div', { class: 'stat__label' }, l))), +); +const list = document.getElementById('list'); +if (!payments.length) { + list.replaceChildren(el('p', { class: 'empty' }, 'No payments yet. Receipts will appear here after your first order.')); +} else { + list.replaceChildren(el('div', { class: 'table-wrap' }, el('table', { class: 'table' }, + el('thead', {}, el('tr', {}, ['Date', 'Order', 'Method', 'Reference', 'Amount', ''].map((h, i) => el('th', { class: i === 4 ? 'num' : '' }, h)))), + el('tbody', {}, payments.map((p) => el('tr', {}, + el('td', { class: 'nowrap' }, fmtDate(p.paidAt)), + el('td', {}, el('a', { class: 'rowlink', href: `/portal/orders/${p.orderCode}` }, p.orderCode), el('div', { class: 'low' }, p.orderTitle)), + el('td', {}, providerName(p.provider)), + el('td', { class: 'mono low' }, p.refId || '—'), + el('td', { class: 'num' }, money(p.amount)), + el('td', {}, el('a', { class: 'btn btn--sm', href: `/portal/receipts/${p.id}` }, 'Receipt')), + ))), + ))); +} diff --git a/pcb-portal/public/js/pages/contact.js b/pcb-portal/public/js/pages/contact.js new file mode 100644 index 0000000..9219e1b --- /dev/null +++ b/pcb-portal/public/js/pages/contact.js @@ -0,0 +1,26 @@ +import { api, el, getConfig, qs } from '../app.js'; + +const cfg = await getConfig(); +const topic = document.getElementById('topic'); +const wanted = qs('topic'); +for (const [v, label] of Object.entries(cfg.inquiryTopics)) topic.append(el('option', { value: v, selected: v === wanted }, label)); + +const form = document.getElementById('contact-form'); +const error = document.getElementById('error'); +const ok = document.getElementById('ok'); +form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + const btn = form.querySelector('button[type=submit]'); + btn.disabled = true; + try { + await api('/contact', { method: 'POST', body: Object.fromEntries(new FormData(form)) }); + form.reset(); + ok.hidden = false; + } catch (err) { + error.textContent = err.message; + error.hidden = false; + } finally { + btn.disabled = false; + } +}); diff --git a/pcb-portal/public/js/pages/dashboard.js b/pcb-portal/public/js/pages/dashboard.js new file mode 100644 index 0000000..4f5b33c --- /dev/null +++ b/pcb-portal/public/js/pages/dashboard.js @@ -0,0 +1,40 @@ +import { api, badge, currentUser, el, fmtDate, getConfig, money } from '../app.js'; + +const me = currentUser(); +if (me) document.getElementById('hello').textContent = `Welcome back, ${me.name.split(' ')[0]}`; +const box = document.getElementById('orders'); +const [{ orders }, cfg] = await Promise.all([api('/orders'), getConfig()]); + +const open = orders.filter((o) => !['completed', 'cancelled'].includes(o.status)); +const spent = orders.filter((o) => ['paid', 'in_progress', 'delivered', 'completed'].includes(o.status)).reduce((s, o) => s + (o.price || 0), 0); +const tiles = [ + ['Open orders', open.length], + ['Awaiting your payment', orders.filter((o) => o.status === 'awaiting_payment').length], + ['Ready to review', orders.filter((o) => o.status === 'delivered').length], + ['Total spent', money(spent)], +]; +document.getElementById('stats').replaceChildren(...tiles.map(([l, v]) => el('div', { class: 'card stat' }, el('div', { class: 'stat__value' }, String(v)), el('div', { class: 'stat__label' }, l)))); + +if (!orders.length) { + box.replaceChildren(el('div', { class: 'empty' }, + el('div', { class: 'dropzone__icon' }, 'PCB'), + el('h3', {}, 'No orders yet'), + el('p', {}, 'Drop a placed .kicad_pcb to get an instant quote and a 3D preview.'), + el('a', { class: 'btn btn--primary', href: '/portal/new' }, 'Start your first order'))); +} else { + box.replaceChildren(el('div', { class: 'table-wrap' }, el('table', { class: 'table' }, + el('thead', {}, el('tr', {}, ['Order', 'Project', 'Service', 'Total', 'Status', 'Updates', 'Placed', ''].map((h) => el('th', {}, h)))), + el('tbody', {}, orders.map((o) => el('tr', {}, + el('td', {}, el('a', { class: 'rowlink', href: `/portal/orders/${o.code}` }, o.code)), + el('td', {}, o.title), + el('td', { class: 'muted' }, cfg.services[o.service] || o.service), + el('td', { class: 'mono' }, money(o.price)), + el('td', {}, badge(o.status, o.statusLabel)), + el('td', { class: 'low' }, [o.deliveredFiles ? `${o.deliveredFiles} file${o.deliveredFiles === 1 ? '' : 's'}` : null, o.engineerMessages ? `${o.engineerMessages} msg` : null].filter(Boolean).join(' · ') || '—'), + el('td', { class: 'low nowrap' }, fmtDate(o.createdAt, false)), + el('td', {}, o.status === 'awaiting_payment' + ? el('a', { class: 'btn btn--primary btn--sm', href: `/portal/orders/${o.code}?pay=1` }, 'Pay now') + : el('a', { class: 'btn btn--sm', href: `/portal/orders/${o.code}` }, 'Open')), + ))), + ))); +} diff --git a/pcb-portal/public/js/pages/evidence.js b/pcb-portal/public/js/pages/evidence.js new file mode 100644 index 0000000..cf95de4 --- /dev/null +++ b/pcb-portal/public/js/pages/evidence.js @@ -0,0 +1,39 @@ +import { el } from '../app.js'; +import { createViewer } from '../viewer/viewer.js'; + +const viewer = createViewer(document.getElementById('viewer'), { + board: { maskColor: 'black', finish: 'enig' }, + onLoad(current) { + if (current?.kind !== 'kicad_pcb') return; + const { board, boardScene } = current; + const s = board.stats; + let total = 0; + for (const v of boardScene.netStats.values()) total += v.length; + document.getElementById('summary').replaceChildren( + ...[ + ['Board', `${s.widthMm} × ${s.heightMm} mm, ${s.copperLayerCount} layers`], + ['Thickness', `${s.thickness} mm`], + ['Footprints / pads', `${s.footprints} / ${s.pads}`], + ['Nets', String(s.nets)], + ['Vias', String(s.vias)], + ['Routed copper', `${total.toFixed(1)} mm`], + ['KiCad format', s.kicadVersion], + ].flatMap(([k, v]) => [el('dt', {}, k), el('dd', { class: 'mono' }, v)]), + ); + const segs = new Map(); + for (const t of board.tracks) if (t.net) segs.set(t.net, (segs.get(t.net) || 0) + t.pts.length - 1); + const rows = [...boardScene.netStats.entries()].sort((a, b) => b[1].length - a[1].length); + document.querySelector('#nets tbody').replaceChildren( + ...rows.map(([net, st]) => + el('tr', {}, + el('td', {}, el('a', { href: '#viewer', class: 'mono', onclick: (e) => { e.preventDefault(); boardScene.highlightNet(net); document.getElementById('viewer').scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, net)), + el('td', { class: 'num' }, `${st.length.toFixed(2)} mm`), + el('td', { class: 'num' }, st.pads), + el('td', { class: 'num' }, st.vias), + el('td', { class: 'num' }, segs.get(net) || 0), + ), + ), + ); + }, +}); +viewer.loadUrl('/samples/demo-board.kicad_pcb', 'demo-board.kicad_pcb').catch(() => {}); diff --git a/pcb-portal/public/js/pages/file-viewer.js b/pcb-portal/public/js/pages/file-viewer.js new file mode 100644 index 0000000..6f62662 --- /dev/null +++ b/pcb-portal/public/js/pages/file-viewer.js @@ -0,0 +1,13 @@ +import { api } from '../app.js'; +import { createViewer } from '../viewer/viewer.js'; + +const id = location.pathname.split('/')[3]; +const target = document.getElementById('viewer'); +try { + const meta = await api(`/files/${encodeURIComponent(id)}/meta`); + document.title = `${meta.name} · 3D view | QodeX PCB`; + const viewer = createViewer(target, { board: meta.look }); + await viewer.loadUrl(`/api/files/${encodeURIComponent(id)}`, meta.name); +} catch (err) { + target.textContent = err.message; +} diff --git a/pcb-portal/public/js/pages/home.js b/pcb-portal/public/js/pages/home.js new file mode 100644 index 0000000..67031b2 --- /dev/null +++ b/pcb-portal/public/js/pages/home.js @@ -0,0 +1,197 @@ +import { createViewer } from '../viewer/viewer.js'; + +// ── hero: live render of the demo board ── +const hero = createViewer(document.getElementById('hero-viewer'), { + autoRotate: true, + transparent: true, + hideGrid: true, + board: { maskColor: 'green', finish: 'enig' }, + onLoad(current) { + if (current?.kind !== 'kicad_pcb') return; + const s = current.board.stats; + let length = 0; + for (const v of current.boardScene.netStats.values()) length += v.length; + animateTo('nets', s.nets); + animateTo('pads', s.pads); + animateTo('vias', s.vias); + animateTo('length', Math.round(length)); + }, +}); +hero.loadUrl('/samples/demo-board.kicad_pcb', 'demo-board.kicad_pcb').catch(() => {}); + +function animateTo(key, target) { + const dd = document.querySelector(`#telemetry [data-k="${key}"]`); + if (!dd) return; + const unit = dd.querySelector('small'); + const start = performance.now(); + const tick = (now) => { + const t = Math.min(1, (now - start) / 1100); + const v = Math.round(target * (1 - Math.pow(1 - t, 3))); + dd.textContent = v.toLocaleString('en-US'); + if (unit) dd.append(unit); + if (t < 1) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} + +// ── studio teaser: second view, lazily created ── +const teaser = document.getElementById('studio-teaser'); +new IntersectionObserver((entries, io) => { + if (!entries.some((e) => e.isIntersecting)) return; + io.disconnect(); + const v = createViewer(teaser, { board: { maskColor: 'matte_black', finish: 'enig' } }); + v.loadUrl('/samples/demo-board.kicad_pcb', 'demo-board.kicad_pcb').then(() => v.setView('top')).catch(() => {}); +}).observe(teaser); + +// ── before / after DFM comparison (procedural 2D drawing) ── +const box = document.getElementById('compare'); +const before = document.getElementById('compare-before'); +const after = document.getElementById('compare-after-canvas'); +const afterWrap = document.getElementById('compare-after'); +const handle = document.getElementById('compare-handle'); + +function drawBoard(canvas, polished) { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = box.clientWidth, h = box.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + const c = canvas.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + c.fillStyle = polished ? '#0f2a1b' : '#1a1f16'; + c.fillRect(0, 0, w, h); + // fine grid + c.strokeStyle = 'rgba(255,255,255,0.035)'; + c.lineWidth = 1; + for (let x = 0; x < w; x += 20) { c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.stroke(); } + for (let y = 0; y < h; y += 20) { c.beginPath(); c.moveTo(0, y); c.lineTo(w, y); c.stroke(); } + + const s = Math.min(w / 640, h / 400); + const X = (v) => v * s + (w - 640 * s) / 2; + const Y = (v) => v * s + (h - 400 * s) / 2; + const copper = polished ? '#d99a55' : '#b7793f'; + const traps = []; + + // IC body + pads + c.fillStyle = '#16171a'; + c.fillRect(X(70), Y(110), 150 * s, 180 * s); + const pins = []; + for (let i = 0; i < 8; i++) pins.push({ x: 220, y: 130 + i * 20 }); + // connector pads on the right + const conn = []; + for (let i = 0; i < 8; i++) conn.push({ x: 560, y: 70 + i * 36 }); + + c.lineCap = polished ? 'round' : 'butt'; + c.lineJoin = polished ? 'round' : 'miter'; + c.strokeStyle = copper; + c.lineWidth = 7 * s; + pins.forEach((p, i) => { + const q = conn[i]; + const midX = 300 + i * 22; + c.beginPath(); + c.moveTo(X(p.x), Y(p.y)); + if (!polished) { + // right-angle dog-legs + c.lineTo(X(midX), Y(p.y)); + c.lineTo(X(midX), Y(q.y)); + c.lineTo(X(q.x), Y(q.y)); + traps.push([midX, p.y], [midX, q.y]); + } else { + // 45° mitred path + const dy = q.y - p.y; + const run = Math.abs(dy); + const x1 = midX - run / 2; + c.lineTo(X(Math.max(p.x + 10, x1)), Y(p.y)); + c.lineTo(X(Math.max(p.x + 10, x1) + run), Y(q.y)); + c.lineTo(X(q.x), Y(q.y)); + } + c.stroke(); + }); + // a branching net: acute T-junction (acid trap) vs. clean perpendicular entry + c.beginPath(); + if (!polished) { + c.moveTo(X(420), Y(360)); + c.lineTo(X(470), Y(340)); + c.moveTo(X(420), Y(360)); + c.lineTo(X(600), Y(360)); + traps.push([432, 356]); + } else { + c.moveTo(X(420), Y(360)); + c.lineTo(X(600), Y(360)); + c.moveTo(X(470), Y(360)); + c.quadraticCurveTo(X(470), Y(345), X(485), Y(340)); + } + c.stroke(); + + // pads (+ teardrops when polished) + c.fillStyle = polished ? '#e7c07a' : '#c49a5c'; + for (const p of pins) { + c.fillRect(X(p.x - 8), Y(p.y - 5), 16 * s, 10 * s); + if (polished) teardrop(c, X(p.x + 8), Y(p.y), 16 * s, 7 * s, copper); + } + for (const q of conn) { + c.beginPath(); + c.arc(X(q.x), Y(q.y), 11 * s, 0, Math.PI * 2); + c.fill(); + c.fillStyle = '#0b0d10'; + c.beginPath(); + c.arc(X(q.x), Y(q.y), 4.5 * s, 0, Math.PI * 2); + c.fill(); + c.fillStyle = polished ? '#e7c07a' : '#c49a5c'; + if (polished) teardrop(c, X(q.x - 10), Y(q.y), -16 * s, 7 * s, copper); + } + // DRC markers + if (!polished) { + c.strokeStyle = '#e5484d'; + c.lineWidth = 1.6; + c.setLineDash([4, 3]); + for (const [x, y] of traps.slice(0, 7)) { + c.beginPath(); + c.arc(X(x), Y(y), 12 * s, 0, Math.PI * 2); + c.stroke(); + } + c.setLineDash([]); + } else { + c.fillStyle = 'rgba(51, 209, 122, 0.9)'; + c.font = `${12 * s}px "Geist Mono", monospace`; + c.fillText('DRC 0 · DFM 0', X(26), Y(385)); + } +} + +function teardrop(c, x, y, len, half, color) { + c.save(); + c.fillStyle = color; + c.beginPath(); + c.moveTo(x, y - half); + c.quadraticCurveTo(x + len * 0.35, y - half * 0.2, x + len, y); + c.quadraticCurveTo(x + len * 0.35, y + half * 0.2, x, y + half); + c.closePath(); + c.fill(); + c.restore(); +} + +function redraw() { + drawBoard(before, false); + drawBoard(after, true); +} +function setSplit(frac) { + const f = Math.max(0.02, Math.min(0.98, frac)); + afterWrap.style.clipPath = `inset(0 0 0 ${f * 100}%)`; + handle.style.left = `${f * 100}%`; + box.setAttribute('aria-valuenow', String(Math.round(f * 100))); +} +let dragging = false; +const fromEvent = (e) => { + const r = box.getBoundingClientRect(); + setSplit((e.clientX - r.left) / r.width); +}; +box.addEventListener('pointerdown', (e) => { dragging = true; box.setPointerCapture(e.pointerId); fromEvent(e); }); +box.addEventListener('pointermove', (e) => dragging && fromEvent(e)); +box.addEventListener('pointerup', () => { dragging = false; }); +box.addEventListener('keydown', (e) => { + const now = Number(box.getAttribute('aria-valuenow')) / 100; + if (e.key === 'ArrowLeft') setSplit(now - 0.05); + if (e.key === 'ArrowRight') setSplit(now + 0.05); +}); +new ResizeObserver(redraw).observe(box); +redraw(); +setSplit(0.5); diff --git a/pcb-portal/public/js/pages/new-order.js b/pcb-portal/public/js/pages/new-order.js new file mode 100644 index 0000000..bcaa241 --- /dev/null +++ b/pcb-portal/public/js/pages/new-order.js @@ -0,0 +1,201 @@ +import { api, dollars, el, extBadge, fmtSize, getConfig, money, qs, uploadForm } from '../app.js'; + +const cfg = await getConfig(); +const form = document.getElementById('order-form'); +const $ = (id) => document.getElementById(id); +const FAB_SERVICES = new Set(['routing_fab', 'fab_only']); +$('max-mb').textContent = cfg.maxUploadMb; + +// ── selects ── +const fill = (select, entries, value) => { + select.replaceChildren(...entries.map(([v, label]) => el('option', { value: v, selected: String(v) === String(value) }, label))); +}; +fill($('service'), Object.entries(cfg.services), qs('service') || 'routing'); +fill($('layers'), cfg.options.layers.map((l) => [l, `${l} layer${l === 1 ? '' : 's'}`]), 2); +fill($('thickness'), cfg.options.thickness.map((t) => [t, `${t.toFixed(1)} mm`]), 1.6); +fill($('copperOz'), cfg.options.copperOz.map((c) => [c, `${c} oz`]), 1); +fill($('finish'), Object.entries(cfg.options.finish), 'enig'); +fill($('maskColor'), Object.entries(cfg.options.maskColor), 'green'); +fill($('silkColor'), Object.entries(cfg.options.silkColor), 'white'); +fill($('turnaround'), Object.entries(cfg.options.turnaround), 'standard'); +fill($('fabHouse'), Object.entries(cfg.options.fabHouse), 'any'); + +// ── files ── +let files = []; +let analyzed = null; +let boardMeta = null; +let boardBuffer = null; +let viewer = null; + +const drop = $('drop'); +const input = $('files'); +drop.addEventListener('click', () => input.click()); +drop.addEventListener('keydown', (e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), input.click())); +drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('is-drag'); }); +drop.addEventListener('dragleave', () => drop.classList.remove('is-drag')); +drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('is-drag'); addFiles(e.dataTransfer.files); }); +input.addEventListener('change', () => { addFiles(input.files); input.value = ''; }); +document.addEventListener('keydown', (e) => { + if (e.key.toLowerCase() === 'u' && !e.ctrlKey && !e.metaKey && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) input.click(); +}); + +function addFiles(list) { + const maxBytes = cfg.maxUploadMb * 1024 * 1024; + for (const f of list) { + if (f.size > maxBytes) { showError(`"${f.name}" is larger than ${cfg.maxUploadMb} MB.`); continue; } + if (!files.some((x) => x.name === f.name && x.size === f.size)) files.push(f); + } + renderFiles(); + const pcb = files.find((f) => f.name.toLowerCase().endsWith('.kicad_pcb')); + if (pcb && pcb !== analyzed) analyze(pcb); +} + +function renderFiles() { + $('file-list').replaceChildren(...files.map((f) => el('li', {}, + extBadge(f.name), + el('span', { class: 'name' }, f.name), + el('span', { class: 'low mono' }, fmtSize(f.size)), + el('button', { type: 'button', class: 'btn btn--sm btn--ghost', 'aria-label': `Remove ${f.name}`, onclick: () => removeFile(f) }, 'Remove')))); +} + +function removeFile(f) { + files = files.filter((x) => x !== f); + if (f === analyzed) { + analyzed = null; + boardMeta = null; + boardBuffer = null; + $('meta-card').hidden = true; + $('preview-card').hidden = true; + viewer?.dispose(); + viewer = null; + } + renderFiles(); + requestQuote(); +} + +async function analyze(file) { + analyzed = file; + $('parse-error').hidden = true; + try { + const { parseKiCadPcb } = await import('../viewer/kicad-parser.js'); + boardBuffer = await file.arrayBuffer(); + const board = parseKiCadPcb(new TextDecoder().decode(boardBuffer)); + boardMeta = board.stats; + const s = board.stats; + $('layers').value = String(cfg.options.layers.find((l) => l >= s.copperLayerCount) ?? 16); + form.widthMm.value = s.widthMm; + form.heightMm.value = s.heightMm; + const nearest = cfg.options.thickness.reduce((a, b) => (Math.abs(b - s.thickness) < Math.abs(a - s.thickness) ? b : a)); + $('thickness').value = String(nearest); + if (!form.title.value) form.title.value = file.name.replace(/\.kicad_pcb$/i, ''); + $('meta').replaceChildren(...[ + ['KiCad format', s.kicadVersion], ['Copper layers', s.copperLayerCount], ['Size', `${s.widthMm} × ${s.heightMm} mm`], + ['Area', `${s.areaCm2} cm²`], ['Footprints', s.footprints], ['Pads', s.pads], ['Nets', s.nets], + ['Existing tracks', s.tracks], ['Vias', s.vias], ['BGA packages', s.hasBGA ? 'Yes' : 'No'], + ].flatMap(([k, v]) => [el('dt', {}, k), el('dd', { class: 'mono' }, String(v))])); + $('meta-card').hidden = false; + $('preview-card').hidden = false; + await renderPreview(); + requestQuote(); + } catch (err) { + boardMeta = null; + $('parse-error').textContent = `We couldn't analyse ${file.name} in the browser (${err.message}). You can still place the order — an engineer will review the file.`; + $('parse-error').hidden = false; + } +} + +async function renderPreview() { + if (!boardBuffer) return; + const { createViewer } = await import('../viewer/viewer.js'); + viewer ??= createViewer($('preview')); + await viewer.loadBuffer(boardBuffer.slice(0), 'board.kicad_pcb', { maskColor: form.maskColor.value, finish: form.finish.value, silkColor: form.silkColor.value }); +} +for (const id of ['maskColor', 'finish', 'silkColor']) $(id).addEventListener('change', () => renderPreview().catch(() => {})); + +// ── specs + live quote ── +function specs() { + const f = new FormData(form); + return { + service: f.get('service'), + layers: Number(f.get('layers')), + widthMm: Number(f.get('widthMm')), + heightMm: Number(f.get('heightMm')), + quantity: Number(f.get('quantity') || 1), + thickness: Number(f.get('thickness')), + copperOz: Number(f.get('copperOz')), + finish: f.get('finish'), + maskColor: f.get('maskColor'), + silkColor: f.get('silkColor'), + turnaround: f.get('turnaround'), + fabHouse: f.get('fabHouse'), + impedanceControl: f.get('impedanceControl') === 'on', + nets: boardMeta?.nets ?? 0, + pads: boardMeta?.pads ?? 0, + }; +} + +function syncService() { + $('fab-fields').hidden = !FAB_SERVICES.has(form.service.value); + $('service-hint').textContent = cfg.serviceDetails[form.service.value]?.summary || ''; +} + +let quoteTimer; +function requestQuote() { + clearTimeout(quoteTimer); + quoteTimer = setTimeout(async () => { + const s = specs(); + if (!(s.widthMm > 0 && s.heightMm > 0)) { + $('price').textContent = '—'; + $('price-lines').replaceChildren(); + $('quote-note').textContent = 'Drop your .kicad_pcb (or enter the board size) to see a price.'; + return; + } + try { + const { quote } = await api('/quote', { method: 'POST', body: { specs: s, boardMeta: boardMeta ? { hasBGA: boardMeta.hasBGA } : null } }); + $('price').replaceChildren(money(quote.total), el('small', {}, 'USD')); + $('price-lines').replaceChildren(...quote.lines.map((l) => el('li', {}, el('span', {}, l.label), el('span', {}, dollars(l.amount))))); + $('quote-note').textContent = quote.autoQuote + ? boardMeta ? 'Computed from your board file. This is the price you will pay.' : 'Upload the .kicad_pcb for an exact price based on nets and pads.' + : 'Preliminary estimate — an engineer confirms the final price before payment.'; + $('submit').textContent = quote.autoQuote ? 'Place order & continue to payment' : 'Place order & request quote'; + } catch (err) { + $('price').textContent = '—'; + $('quote-note').textContent = err.message; + } + }, 200); +} +form.addEventListener('input', requestQuote); +form.addEventListener('change', () => { syncService(); requestQuote(); }); +syncService(); +requestQuote(); + +// ── submit ── +function showError(msg) { + $('error').textContent = msg; + $('error').hidden = !msg; +} + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + showError(''); + if (!files.length) return showError('Add at least one project file — ideally the .kicad_pcb.'); + if (form.title.value.trim().length < 2) return showError('Give the project a title.'); + const fd = new FormData(); + fd.append('title', form.title.value.trim()); + fd.append('notes', form.notes.value); + fd.append('specs', JSON.stringify(specs())); + for (const f of files) fd.append('files', f, f.name); + const btn = $('submit'); + btn.disabled = true; + const prog = $('progress'); + prog.hidden = false; + try { + const res = await uploadForm('/orders', fd, (p) => { prog.textContent = `Uploading… ${Math.round(p * 100)}%`; }); + prog.textContent = 'Order placed. Redirecting…'; + location.href = `/portal/orders/${res.order.code}${res.order.status === 'awaiting_payment' ? '?pay=1' : ''}`; + } catch (err) { + showError(err.message); + prog.hidden = true; + btn.disabled = false; + } +}); diff --git a/pcb-portal/public/js/pages/order.js b/pcb-portal/public/js/pages/order.js new file mode 100644 index 0000000..34bec58 --- /dev/null +++ b/pcb-portal/public/js/pages/order.js @@ -0,0 +1,17 @@ +import { api, getConfig } from '../app.js'; +import { renderOrder } from '../order-view.js'; + +const root = document.getElementById('app'); +const code = decodeURIComponent(location.pathname.split('/').pop()); +const cfg = await getConfig(); + +async function load() { + try { + const detail = await api(`/orders/${encodeURIComponent(code)}`); + document.title = `${detail.order.code} · ${detail.order.title} | QodeX PCB`; + renderOrder(root, detail, { admin: false, cfg, reload: load }); + } catch (err) { + root.textContent = err.message; + } +} +load(); diff --git a/pcb-portal/public/js/pages/pay-mock.js b/pcb-portal/public/js/pages/pay-mock.js new file mode 100644 index 0000000..2569c49 --- /dev/null +++ b/pcb-portal/public/js/pages/pay-mock.js @@ -0,0 +1,20 @@ +import { api, el, money, toast } from '../app.js'; + +const pid = location.pathname.split('/').pop(); +const info = document.getElementById('info'); +try { + const p = await api(`/payments/mock/${encodeURIComponent(pid)}`); + for (const [k, v] of [['Order', `${p.order.code} — ${p.order.title}`], ['Amount', money(p.amount)], ['Status', p.status]]) info.append(el('dt', {}, k), el('dd', {}, v)); +} catch (err) { + info.textContent = err.message; +} +for (const [id, outcome] of [['ok', 'OK'], ['nok', 'NOK']]) { + document.getElementById(id).addEventListener('click', async () => { + try { + const { redirectUrl } = await api(`/payments/mock/${encodeURIComponent(pid)}`, { method: 'POST', body: { outcome } }); + location.href = redirectUrl; + } catch (err) { + toast(err.message, true); + } + }); +} diff --git a/pcb-portal/public/js/pages/pricing.js b/pcb-portal/public/js/pages/pricing.js new file mode 100644 index 0000000..b56231f --- /dev/null +++ b/pcb-portal/public/js/pages/pricing.js @@ -0,0 +1,50 @@ +import { api, dollars, el, getConfig, money } from '../app.js'; + +const cfg = await getConfig(); +const form = document.getElementById('estimator'); +const fill = (select, entries, value) => { + for (const [v, label] of entries) select.append(el('option', { value: v, selected: v === value }, label)); +}; +fill(document.getElementById('service'), Object.entries(cfg.services), new URLSearchParams(location.search).get('service') || 'routing'); +fill(document.getElementById('finish'), Object.entries(cfg.options.finish), 'enig'); +fill(document.getElementById('turnaround'), Object.entries(cfg.options.turnaround), 'standard'); +const layersOut = document.getElementById('layers-out'); + +const services = document.getElementById('services'); +for (const [key, label] of Object.entries(cfg.services)) { + const d = cfg.serviceDetails[key]; + services.append(el('div', { class: 'card feature' }, + el('h3', {}, label), + el('p', {}, d.summary), + el('ul', { class: 'checklist', style: 'margin-top:14px' }, d.deliverables.map((x) => el('li', {}, x))), + )); +} + +let timer; +async function update() { + const f = new FormData(form); + const layers = cfg.options.layers[Number(f.get('layersIdx'))]; + layersOut.textContent = `${layers}`; + const specs = { + service: f.get('service'), + layers, + widthMm: Number(f.get('widthMm')), + heightMm: Number(f.get('heightMm')), + quantity: Number(f.get('quantity')) || 1, + nets: Number(f.get('nets')) || 0, + pads: Number(f.get('pads')) || 0, + finish: f.get('finish'), + turnaround: f.get('turnaround'), + impedanceControl: f.get('impedanceControl') === 'on', + }; + try { + const { quote } = await api('/quote', { method: 'POST', body: { specs, boardMeta: { hasBGA: f.get('hasBGA') === 'on' } } }); + document.getElementById('price').replaceChildren(money(quote.total), el('small', {}, 'USD')); + document.getElementById('lines').replaceChildren(...quote.lines.map((l) => el('li', {}, el('span', {}, l.label), el('span', {}, dollars(l.amount))))); + } catch (err) { + document.getElementById('price').textContent = '—'; + document.getElementById('lines').replaceChildren(el('li', {}, el('span', {}, err.message))); + } +} +form.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(update, 120); }); +update(); diff --git a/pcb-portal/public/js/pages/receipt.js b/pcb-portal/public/js/pages/receipt.js new file mode 100644 index 0000000..4f88325 --- /dev/null +++ b/pcb-portal/public/js/pages/receipt.js @@ -0,0 +1,32 @@ +import { api, dollars, el, fmtDate, money } from '../app.js'; +import { providerName } from '../order-view.js'; + +document.getElementById('print').addEventListener('click', () => window.print()); +const box = document.getElementById('receipt'); +const pid = location.pathname.split('/').pop(); +try { + const r = await api(`/payments/${encodeURIComponent(pid)}/receipt`); + document.title = `Receipt ${r.order.code} | QodeX PCB`; + const lines = r.order.lines || [{ label: r.order.serviceLabel, amount: r.payment.amount / 100 }]; + box.replaceChildren( + el('div', { class: 'row' }, el('div', {}, el('h1', { style: 'font-size:28px;margin:0' }, 'Receipt'), el('div', { class: 'muted mono' }, `${r.order.code} · ${r.payment.id}`)), el('span', { class: 'spacer' }), el('span', { class: 'paid-stamp' }, 'PAID')), + el('div', { class: 'receipt__grid' }, + el('div', {}, el('div', { class: 'low' }, 'From'), el('strong', {}, r.seller.name), el('div', {}, r.seller.site), r.seller.email ? el('div', {}, r.seller.email) : null, el('div', { class: 'muted' }, r.seller.url)), + el('div', {}, el('div', { class: 'low' }, 'Billed to'), el('strong', {}, r.customer.name), r.customer.company ? el('div', {}, r.customer.company) : null, el('div', {}, r.customer.email), r.customer.country ? el('div', {}, r.customer.country) : null), + ), + el('div', { class: 'receipt__grid' }, + el('div', {}, el('div', { class: 'low' }, 'Date paid'), el('div', {}, fmtDate(r.payment.paidAt))), + el('div', {}, el('div', { class: 'low' }, 'Payment method'), el('div', {}, `${providerName(r.payment.provider)} · ref ${r.payment.refId || '—'}`)), + ), + el('table', {}, + el('thead', {}, el('tr', {}, el('th', {}, `${r.order.serviceLabel} — ${r.order.title}`), el('th', { class: 'num' }, 'Amount (USD)'))), + el('tbody', {}, + lines.map((l) => el('tr', {}, el('td', {}, l.label), el('td', { class: 'num' }, dollars(l.amount)))), + el('tr', { class: 'total' }, el('td', {}, 'Total paid'), el('td', { class: 'num' }, money(r.payment.amount))), + ), + ), + el('p', { class: 'low' }, 'Line items may not sum exactly to the total because of rounding and minimum-order adjustments. Thank you for your order.'), + ); +} catch (err) { + box.textContent = err.message; +} diff --git a/pcb-portal/public/js/pages/settings.js b/pcb-portal/public/js/pages/settings.js new file mode 100644 index 0000000..a50e8d9 --- /dev/null +++ b/pcb-portal/public/js/pages/settings.js @@ -0,0 +1,27 @@ +import { api, toast } from '../app.js'; + +const profile = document.getElementById('profile'); +const pw = document.getElementById('password'); +const { user } = await api('/auth/me'); +for (const k of ['email', 'name', 'company', 'country', 'phone']) profile.elements[k].value = user?.[k] || ''; + +profile.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(profile)); + try { + await api('/auth/me', { method: 'PATCH', body: data }); + toast('Profile saved.'); + } catch (err) { + toast(err.message, true); + } +}); +pw.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await api('/auth/me', { method: 'PATCH', body: Object.fromEntries(new FormData(pw)) }); + pw.reset(); + toast('Password changed. Other sessions were signed out.'); + } catch (err) { + toast(err.message, true); + } +}); diff --git a/pcb-portal/public/js/pages/studio.js b/pcb-portal/public/js/pages/studio.js new file mode 100644 index 0000000..630ec45 --- /dev/null +++ b/pcb-portal/public/js/pages/studio.js @@ -0,0 +1,41 @@ +import { toast } from '../app.js'; +import { createViewer, viewerKindFor } from '../viewer/viewer.js'; + +const mask = document.getElementById('mask'); +const finish = document.getElementById('finish'); +const fname = document.getElementById('fname'); +const viewer = createViewer(document.getElementById('viewer')); +let lastBuffer = null; +let lastName = 'demo-board.kicad_pcb'; + +async function show(buffer, name) { + lastBuffer = buffer; + lastName = name; + fname.textContent = name; + await viewer.loadBuffer(buffer.slice(0), name, { maskColor: mask.value, finish: finish.value }); +} + +fetch('/samples/demo-board.kicad_pcb') + .then((r) => r.arrayBuffer()) + .then((b) => show(b, 'demo-board.kicad_pcb')) + .catch(() => {}); + +async function open(file) { + if (!file) return; + if (!viewerKindFor(file.name)) return toast('Unsupported format. Open .kicad_pcb, .glb, .gltf, .stl, .obj or .wrl.', true); + try { + await show(await file.arrayBuffer(), file.name); + } catch (err) { + toast(err.message, true); + } +} +for (const sel of [mask, finish]) sel.addEventListener('change', () => lastBuffer && lastName.endsWith('.kicad_pcb') && show(lastBuffer, lastName).catch(() => {})); +document.getElementById('file').addEventListener('change', (e) => open(e.target.files[0])); +const drop = document.getElementById('drop'); +drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('is-drag'); }); +drop.addEventListener('dragleave', (e) => { if (!drop.contains(e.relatedTarget)) drop.classList.remove('is-drag'); }); +drop.addEventListener('drop', (e) => { + e.preventDefault(); + drop.classList.remove('is-drag'); + open(e.dataTransfer.files[0]); +}); diff --git a/pcb-portal/public/js/viewer/kicad-parser.js b/pcb-portal/public/js/viewer/kicad-parser.js new file mode 100644 index 0000000..df103ed --- /dev/null +++ b/pcb-portal/public/js/viewer/kicad-parser.js @@ -0,0 +1,579 @@ +// KiCad .kicad_pcb parser — pure JS, no DOM / three.js dependency. +// +// Shared by the browser (3D viewer) and the server (board metadata for quoting). +// Handles KiCad 5 (`module`) through KiCad 9 (`footprint`) boards. All +// coordinates are returned in KiCad file space: millimetres, +Y pointing DOWN. +// Angles are degrees, counter-clockwise as seen on screen. + +/** Tokenise + parse an S-expression into nested arrays of strings. */ +export function parseSExpr(text) { + const len = text.length; + let i = 0; + const stack = []; + let current = null; + let root = null; + + while (i < len) { + const c = text.charCodeAt(i); + if (c === 40 /* ( */) { + const list = []; + if (current) current.push(list); + stack.push(current); + current = list; + i++; + } else if (c === 41 /* ) */) { + if (!current) throw new SExprError('Unexpected ")"', text, i); + const done = current; + current = stack.pop(); + if (!current && root === null) root = done; + i++; + if (!current && root) break; + } else if (c === 34 /* " */) { + let j = i + 1; + let out = ''; + while (j < len) { + const d = text.charCodeAt(j); + if (d === 92 /* \ */ && j + 1 < len) { + const n = text[j + 1]; + out += n === 'n' ? '\n' : n === 't' ? '\t' : n; + j += 2; + } else if (d === 34) break; + else { out += text[j]; j++; } + } + if (j >= len) throw new SExprError('Unterminated string', text, i); + if (!current) throw new SExprError('String outside of a list', text, i); + current.push(out); + i = j + 1; + } else if (c === 32 || c === 9 || c === 10 || c === 13) { + i++; + } else { + let j = i; + while (j < len) { + const d = text.charCodeAt(j); + if (d === 40 || d === 41 || d === 32 || d === 9 || d === 10 || d === 13) break; + j++; + } + if (!current) throw new SExprError('Atom outside of a list', text, i); + current.push(text.slice(i, j)); + i = j; + } + } + if (current || stack.length) throw new SExprError('Unbalanced parentheses: missing ")"', text, len - 1); + if (!root) throw new SExprError('Empty document', text, 0); + return root; +} + +export class SExprError extends Error { + constructor(message, text, offset) { + let line = 1; + for (let k = 0; k < offset && k < text.length; k++) if (text.charCodeAt(k) === 10) line++; + super(`${message} (line ${line})`); + this.name = 'SExprError'; + this.line = line; + } +} + +// ── small helpers over the nested-array tree ──────────────────────────────── +const isList = (n) => Array.isArray(n); +const head = (n) => (isList(n) ? n[0] : undefined); +const child = (n, name) => n.find((c) => isList(c) && c[0] === name); +const children = (n, name) => n.filter((c) => isList(c) && c[0] === name); +const num = (v, d = 0) => { + const f = parseFloat(v); + return Number.isFinite(f) ? f : d; +}; +const xy = (n) => (n ? { x: num(n[1]), y: num(n[2]) } : null); +const layerOf = (n) => { + const l = child(n, 'layer'); + return l ? l[1] : undefined; +}; +const widthOf = (n) => { + const w = child(n, 'width'); + if (w) return num(w[1]); + const s = child(n, 'stroke'); + if (s) { + const sw = child(s, 'width'); + if (sw) return num(sw[1]); + } + return 0.15; +}; + +const DEG = Math.PI / 180; + +/** Rotate a local point by `deg` (KiCad convention, Y-down) and translate. */ +export function kicadTransform(lx, ly, ox, oy, deg) { + if (!deg) return { x: ox + lx, y: oy + ly }; + const c = Math.cos(deg * DEG); + const s = Math.sin(deg * DEG); + return { x: ox + lx * c + ly * s, y: oy - lx * s + ly * c }; +} + +/** Points along a circular arc through start → mid → end (KiCad 6+ form). */ +export function arcThrough(p1, p2, p3, maxSegLen = 0.5) { + const ax = p1.x, ay = p1.y, bx = p2.x, by = p2.y, cx = p3.x, cy = p3.y; + const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + if (Math.abs(d) < 1e-12) return [p1, p3]; + const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d; + const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d; + const r = Math.hypot(ax - ux, ay - uy); + const a1 = Math.atan2(ay - uy, ax - ux); + const a2 = Math.atan2(by - uy, bx - ux); + const a3 = Math.atan2(cy - uy, cx - ux); + // choose sweep direction that passes through the mid point + const norm = (a) => ((a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + let sweep = norm(a3 - a1); + if (norm(a2 - a1) > sweep) sweep -= 2 * Math.PI; + return arcPoints(ux, uy, r, a1, sweep, maxSegLen); +} + +/** KiCad 5 style arc: centre, start point, sweep angle in degrees. */ +export function arcFromCentre(centre, start, angleDeg, maxSegLen = 0.5) { + const r = Math.hypot(start.x - centre.x, start.y - centre.y); + const a1 = Math.atan2(start.y - centre.y, start.x - centre.x); + // In Y-down space a positive KiCad angle is clockwise on screen → +atan2 direction. + return arcPoints(centre.x, centre.y, r, a1, angleDeg * DEG, maxSegLen); +} + +function arcPoints(cx, cy, r, a1, sweep, maxSegLen) { + const n = Math.max(2, Math.min(128, Math.ceil((Math.abs(sweep) * r) / maxSegLen))); + const pts = []; + for (let k = 0; k <= n; k++) { + const a = a1 + (sweep * k) / n; + pts.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }); + } + return pts; +} + +function circlePoints(cx, cy, r, n = 48) { + const pts = []; + for (let k = 0; k < n; k++) { + const a = (2 * Math.PI * k) / n; + pts.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }); + } + return pts; +} + +/** + * Convert one graphic primitive (gr_* / fp_*) into polylines. + * `tf` maps local → board coordinates (identity for board-level graphics). + * Returns { layer, width, closed, filled, pts[] } or null. + */ +function graphicToPolyline(node, tf) { + const kind = head(node).replace(/^(gr|fp)_/, ''); + const layer = layerOf(node); + const width = widthOf(node); + const fillNode = child(node, 'fill'); + const filled = !!fillNode && (fillNode[1] === 'solid' || fillNode[1] === 'yes'); + const P = (n) => { + const p = xy(n); + return p ? tf(p.x, p.y) : null; + }; + switch (kind) { + case 'line': { + const a = P(child(node, 'start')); + const b = P(child(node, 'end')); + return a && b ? { layer, width, closed: false, filled: false, pts: [a, b] } : null; + } + case 'rect': { + const s = xy(child(node, 'start')); + const e = xy(child(node, 'end')); + if (!s || !e) return null; + const pts = [tf(s.x, s.y), tf(e.x, s.y), tf(e.x, e.y), tf(s.x, e.y)]; + return { layer, width, closed: true, filled, pts }; + } + case 'circle': { + const c = xy(child(node, 'center')); + const e = xy(child(node, 'end')); + if (!c || !e) return null; + const r = Math.hypot(e.x - c.x, e.y - c.y); + const pts = circlePoints(c.x, c.y, r).map((p) => tf(p.x, p.y)); + return { layer, width, closed: true, filled, pts }; + } + case 'arc': { + const mid = child(node, 'mid'); + let local; + if (mid) { + local = arcThrough(xy(child(node, 'start')), xy(mid), xy(child(node, 'end'))); + } else { + // KiCad 5: (start = centre) (end = arc start) (angle) + const ang = child(node, 'angle'); + local = arcFromCentre(xy(child(node, 'start')), xy(child(node, 'end')), num(ang && ang[1])); + } + return { layer, width, closed: false, filled: false, pts: local.map((p) => tf(p.x, p.y)) }; + } + case 'poly': { + const ptsNode = child(node, 'pts'); + if (!ptsNode) return null; + const pts = collectPts(ptsNode).map((p) => tf(p.x, p.y)); + return { layer, width, closed: true, filled: filled || !fillNode, pts }; + } + case 'curve': { + const ptsNode = child(node, 'pts'); + if (!ptsNode) return null; + const cp = collectPts(ptsNode); + if (cp.length !== 4) return { layer, width, closed: false, filled: false, pts: cp.map((p) => tf(p.x, p.y)) }; + const pts = []; + for (let k = 0; k <= 16; k++) { + const t = k / 16, u = 1 - t; + pts.push({ + x: u * u * u * cp[0].x + 3 * u * u * t * cp[1].x + 3 * u * t * t * cp[2].x + t * t * t * cp[3].x, + y: u * u * u * cp[0].y + 3 * u * u * t * cp[1].y + 3 * u * t * t * cp[2].y + t * t * t * cp[3].y, + }); + } + return { layer, width, closed: false, filled: false, pts: pts.map((p) => tf(p.x, p.y)) }; + } + default: + return null; + } +} + +/** Collect (xy ..) and (arc ..) entries of a (pts ...) node into a flat point list. */ +function collectPts(ptsNode) { + const out = []; + for (const c of ptsNode) { + if (!isList(c)) continue; + if (c[0] === 'xy') out.push({ x: num(c[1]), y: num(c[2]) }); + else if (c[0] === 'arc') { + const pts = arcThrough(xy(child(c, 'start')), xy(child(c, 'mid')), xy(child(c, 'end'))); + out.push(...pts); + } + } + return out; +} + +const COPPER_ORDER = (name) => { + if (name === 'F.Cu') return -1; + if (name === 'B.Cu') return 1000; + const m = /^In(\d+)\.Cu$/.exec(name); + return m ? Number(m[1]) : 500; +}; + +/** Expand a pad/via layer list (supports `*.Cu`, `F&B.Cu`) against the board's copper layers. */ +function expandLayers(list, copper) { + const out = new Set(); + for (const l of list) { + if (l === '*.Cu') copper.forEach((c) => out.add(c)); + else if (l === 'F&B.Cu') { out.add('F.Cu'); out.add('B.Cu'); } + else if (l === '*.Mask') { out.add('F.Mask'); out.add('B.Mask'); } + else out.add(l); + } + return [...out]; +} + +/** Chain loose outline segments into closed loops. */ +export function chainLoops(polys, tol = 0.02) { + const closed = []; + const open = []; + for (const p of polys) { + if (p.closed) closed.push(p.pts.slice()); + else if (p.pts.length >= 2) open.push(p.pts.slice()); + } + const near = (a, b) => Math.abs(a.x - b.x) <= tol && Math.abs(a.y - b.y) <= tol; + while (open.length) { + let chain = open.pop(); + let progressed = true; + while (progressed && !near(chain[0], chain[chain.length - 1])) { + progressed = false; + const end = chain[chain.length - 1]; + for (let k = 0; k < open.length; k++) { + const s = open[k]; + if (near(s[0], end)) chain = chain.concat(s.slice(1)); + else if (near(s[s.length - 1], end)) chain = chain.concat(s.slice(0, -1).reverse()); + else continue; + open.splice(k, 1); + progressed = true; + break; + } + } + if (chain.length >= 3 && near(chain[0], chain[chain.length - 1])) { + chain.pop(); + closed.push(chain); + } + } + return closed; +} + +export function polygonArea(pts) { + let a = 0; + for (let k = 0, n = pts.length; k < n; k++) { + const p = pts[k], q = pts[(k + 1) % n]; + a += p.x * q.y - q.x * p.y; + } + return a / 2; +} + +function bboxOf(points, bb = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }) { + for (const p of points) { + if (p.x < bb.minX) bb.minX = p.x; + if (p.y < bb.minY) bb.minY = p.y; + if (p.x > bb.maxX) bb.maxX = p.x; + if (p.y > bb.maxY) bb.maxY = p.y; + } + return bb; +} + +/** + * Parse a .kicad_pcb document into a flat, render-ready board model. + * Throws SExprError / Error with a readable message on malformed input. + */ +export function parseKiCadPcb(text) { + const root = parseSExpr(text); + if (head(root) !== 'kicad_pcb') throw new Error('Not a KiCad PCB file: root element is "' + head(root) + '"'); + + const version = child(root, 'version'); + const generator = child(root, 'generator'); + const general = child(root, 'general'); + const thicknessNode = general && child(general, 'thickness'); + const thickness = thicknessNode ? num(thicknessNode[1], 1.6) : 1.6; + + // ── layers ── + const layersNode = child(root, 'layers'); + const layers = []; + if (layersNode) { + for (const l of layersNode.slice(1)) { + if (isList(l)) layers.push({ ordinal: num(l[0]), name: l[1], type: l[2], user: l[3] }); + } + } + let copperLayers = layers.filter((l) => /\.Cu$/.test(l.name)).map((l) => l.name); + if (!copperLayers.length) copperLayers = ['F.Cu', 'B.Cu']; + copperLayers.sort((a, b) => COPPER_ORDER(a) - COPPER_ORDER(b)); + + // ── stackup (optional) ── + const setup = child(root, 'setup'); + const stackupNode = setup && child(setup, 'stackup'); + const stackup = []; + if (stackupNode) { + for (const l of children(stackupNode, 'layer')) { + const t = child(l, 'thickness'); + const type = child(l, 'type'); + const mat = child(l, 'material'); + const color = child(l, 'color'); + stackup.push({ name: l[1], type: type && type[1], thickness: t ? num(t[1]) : 0, material: mat && mat[1], color: color && color[1] }); + } + } + + const netNodes = children(root, 'net'); + const nets = netNodes.length; + const netNames = new Map(netNodes.map((n) => [String(n[1]), n[2] ?? ''])); + // (net 3) → "GND"; newer files may carry (net "GND") directly + const netOf = (node) => { + const n = child(node, 'net'); + if (!n) return null; + if (n[2] !== undefined) return n[2] || null; + return netNames.has(String(n[1])) ? netNames.get(String(n[1])) || null : String(n[1]); + }; + const edge = []; + const graphics = []; + const tracks = []; + const vias = []; + const pads = []; + const zones = []; + const footprints = []; + const identity = (x, y) => ({ x, y }); + + for (const n of root) { + if (!isList(n)) continue; + const h = n[0]; + if (/^gr_(line|rect|circle|arc|poly|curve)$/.test(h)) { + const p = graphicToPolyline(n, identity); + if (!p) continue; + if (p.layer === 'Edge.Cuts') edge.push(p); + else graphics.push(p); + } else if (h === 'segment') { + const a = xy(child(n, 'start')), b = xy(child(n, 'end')); + if (a && b) tracks.push({ layer: layerOf(n), width: widthOf(n), pts: [a, b], net: netOf(n) }); + } else if (h === 'arc') { + const pts = arcThrough(xy(child(n, 'start')), xy(child(n, 'mid')), xy(child(n, 'end')), 0.3); + tracks.push({ layer: layerOf(n), width: widthOf(n), pts, net: netOf(n) }); + } else if (h === 'via') { + const at = xy(child(n, 'at')); + const size = child(n, 'size'); + const drill = child(n, 'drill'); + const lnode = child(n, 'layers'); + const vl = lnode ? expandLayers(lnode.slice(1), copperLayers) : ['F.Cu', 'B.Cu']; + const idx = vl.map((l) => copperLayers.indexOf(l)).filter((k) => k >= 0); + const from = idx.length ? copperLayers[Math.min(...idx)] : 'F.Cu'; + const to = idx.length ? copperLayers[Math.max(...idx)] : 'B.Cu'; + const typeAtom = n.find((c) => c === 'blind' || c === 'micro'); + if (at) vias.push({ x: at.x, y: at.y, size: num(size && size[1], 0.6), drill: num(drill && drill[1], 0.3), from, to, type: typeAtom || 'through', net: netOf(n) }); + } else if (h === 'zone') { + for (const fp of children(n, 'filled_polygon')) { + const layer = layerOf(fp) || layerOf(n); + const ptsNode = child(fp, 'pts'); + if (ptsNode) zones.push({ layer, pts: collectPts(ptsNode), net: netOf(n) || (child(n, 'net_name') || [])[1] || null }); + } + } else if (h === 'footprint' || h === 'module') { + parseFootprint(n, copperLayers, footprints, pads, graphics, edge); + } + } + + // ── board outline ── + let loops = chainLoops(edge); + let bbox; + if (loops.length) { + loops.sort((a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a))); + bbox = bboxOf(loops[0]); + } else { + bbox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + for (const t of tracks) bboxOf(t.pts, bbox); + for (const p of pads) bboxOf([{ x: p.x, y: p.y }], bbox); + for (const f of footprints) bboxOf(f.corners, bbox); + if (!Number.isFinite(bbox.minX)) bbox = { minX: 0, minY: 0, maxX: 50, maxY: 50 }; + const m = 2; + bbox = { minX: bbox.minX - m, minY: bbox.minY - m, maxX: bbox.maxX + m, maxY: bbox.maxY + m }; + loops = [[{ x: bbox.minX, y: bbox.minY }, { x: bbox.maxX, y: bbox.minY }, { x: bbox.maxX, y: bbox.maxY }, { x: bbox.minX, y: bbox.maxY }]]; + } + + const widthMm = +(bbox.maxX - bbox.minX).toFixed(2); + const heightMm = +(bbox.maxY - bbox.minY).toFixed(2); + const hasBGA = footprints.some((f) => /BGA/i.test(f.lib)) || footprints.some((f) => f.padCount >= 64 && f.gridPads); + + return { + version: version ? version[1] : null, + generator: generator ? generator[1] : null, + thickness, + layers, + copperLayers, + stackup, + outline: { outer: loops[0], holes: loops.slice(1) }, + bbox, + tracks, + vias, + pads, + zones, + graphics, + footprints, + stats: { + copperLayerCount: copperLayers.length, + widthMm, + heightMm, + areaCm2: +((Math.abs(polygonArea(loops[0])) - loops.slice(1).reduce((s, l) => s + Math.abs(polygonArea(l)), 0)) / 100).toFixed(2), + footprints: footprints.length, + pads: pads.length, + tracks: tracks.length, + vias: vias.length, + nets: Math.max(0, nets - 1), + zones: zones.length, + hasBGA, + thickness, + kicadVersion: kicadVersionLabel(version && version[1]), + }, + }; +} + +function kicadVersionLabel(v) { + const n = Number(v); + if (!n) return 'unknown'; + if (n >= 20241229) return '9.x'; + if (n >= 20240108) return '8.x'; + if (n >= 20221018) return '7.x'; + if (n >= 20211014) return '6.x'; + return '5.x'; +} + +function parseFootprint(n, copperLayers, footprints, pads, graphics, edge) { + const at = child(n, 'at'); + const fx = num(at && at[1]), fy = num(at && at[2]), frot = num(at && at[3]); + const layer = layerOf(n) || 'F.Cu'; + const side = layer === 'B.Cu' ? 'B' : 'F'; + const tf = (x, y) => kicadTransform(x, y, fx, fy, frot); + + // reference / value (KiCad 5–7: fp_text reference; KiCad 8+: property "Reference") + let ref = '', value = ''; + for (const t of children(n, 'fp_text')) { + if (t[1] === 'reference') ref = t[2]; + if (t[1] === 'value') value = t[2]; + } + for (const p of children(n, 'property')) { + if (p[1] === 'Reference') ref = p[2]; + if (p[1] === 'Value') value = p[2]; + } + + // body extents in local coordinates, preferring the fabrication outline + const local = { fab: [], crtyd: [], pads: [] }; + for (const g of n) { + if (!isList(g) || !/^fp_(line|rect|circle|arc|poly|curve)$/.test(g[0])) continue; + const lp = graphicToPolyline(g, identityTf); + if (!lp) continue; + if (/\.Fab$/.test(lp.layer)) local.fab.push(...lp.pts); + else if (/\.CrtYd$/.test(lp.layer)) local.crtyd.push(...lp.pts); + const wp = { ...lp, pts: lp.pts.map((p) => tf(p.x, p.y)) }; + if (lp.layer === 'Edge.Cuts') edge.push(wp); + else if (/\.(SilkS|Silkscreen)$/.test(lp.layer)) graphics.push(wp); + } + + let padCount = 0; + const padXs = new Set(); + for (const p of children(n, 'pad')) { + const pat = child(p, 'at'); + const px = num(pat && pat[1]), py = num(pat && pat[2]); + // pad angle in the file already includes the footprint orientation + const prot = pat && pat[3] !== undefined ? num(pat[3]) : frot; + const size = child(p, 'size'); + const w = num(size && size[1], 1), hgt = num(size && size[2], w); + const drillNode = child(p, 'drill'); + let drill = null; + if (drillNode) { + if (drillNode[1] === 'oval') drill = { w: num(drillNode[2]), h: num(drillNode[3], num(drillNode[2])) }; + else if (drillNode[1] !== undefined && !isList(drillNode[1])) drill = { w: num(drillNode[1]), h: num(drillNode[1]) }; + if (drill && drill.w <= 0) drill = null; + } + const lnode = child(p, 'layers'); + const plist = lnode ? expandLayers(lnode.slice(1), copperLayers) : []; + const rr = child(p, 'roundrect_rratio'); + const world = tf(px, py); + const netNode = child(p, 'net'); + pads.push({ + number: p[1], + x: world.x, + y: world.y, + rot: prot, + type: p[2], + shape: p[3], + w, + h: hgt, + rratio: rr ? num(rr[1]) : 0.25, + drill, + layers: plist, + net: netNode ? netNode[2] || netNode[1] : null, + footprint: ref, + }); + padCount++; + padXs.add(px.toFixed(2)); + local.pads.push({ x: px - w / 2, y: py - hgt / 2 }, { x: px + w / 2, y: py + hgt / 2 }); + } + + const src = local.fab.length ? local.fab : local.crtyd.length ? local.crtyd : local.pads; + let bb = src.length ? bboxOf(src) : { minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }; + const corners = [ + { x: bb.minX, y: bb.minY }, { x: bb.maxX, y: bb.minY }, + { x: bb.maxX, y: bb.maxY }, { x: bb.minX, y: bb.maxY }, + ].map((p) => tf(p.x, p.y)); + + const libNode = n[1]; + footprints.push({ + lib: typeof libNode === 'string' ? libNode : '', + ref, + value, + x: fx, + y: fy, + rot: frot, + side, + local: bb, + corners, + padCount, + gridPads: padCount >= 16 && padXs.size >= 4 && padXs.size * padXs.size >= padCount * 0.6, + smdOnly: children(n, 'pad').every((p) => p[2] === 'smd'), + hasModel: !!child(n, 'model'), + }); +} + +function identityTf(x, y) { + return { x, y }; +} + +/** Lightweight summary for quoting (no geometry arrays). */ +export function summarizeKiCadPcb(text) { + const b = parseKiCadPcb(text); + return { ...b.stats, copperLayers: b.copperLayers }; +} diff --git a/pcb-portal/public/js/viewer/kicad-scene.js b/pcb-portal/public/js/viewer/kicad-scene.js new file mode 100644 index 0000000..40c0d80 --- /dev/null +++ b/pcb-portal/public/js/viewer/kicad-scene.js @@ -0,0 +1,464 @@ +// Builds a three.js scene graph from a parsed KiCad board model. +// +// Coordinate mapping: KiCad file space is (x right, y DOWN, mm). The board is +// built in a local XY plane with Y flipped (y → -y) and Z as board thickness, +// then the whole group is rotated so the board lies flat (Z-up → Y-up). +import * as THREE from 'three'; + +export const MASK_COLORS = { + green: 0x145a2e, + black: 0x111214, + white: 0xe8e8e2, + blue: 0x1d3f7a, + red: 0x8a1c1c, + yellow: 0xb99a13, + purple: 0x4b2a6b, + matte_black: 0x151515, + matte_green: 0x24503a, +}; + +export const FINISH_COLORS = { + enig: 0xd4a94c, + hasl: 0xc9ccd1, + lf_hasl: 0xc9ccd1, + osp: 0xc27a47, + immersion_silver: 0xdadde2, + hard_gold: 0xe0b049, +}; + +const COPPER = 0xc8773f; +const FR4 = 0xbfa36a; +const SEG = 10; // segments per semicircle + +// ── triangle accumulator ──────────────────────────────────────────────────── +class TriBuffer { + constructor() { + this.pos = []; + } + tri(ax, ay, bx, by, cx, cy, z) { + this.pos.push(ax, ay, z, bx, by, z, cx, cy, z); + } + /** capsule (track segment with round ends) in flipped space */ + capsule(x1, y1, x2, y2, w, z) { + const r = w / 2; + const dx = x2 - x1, dy = y2 - y1; + const len = Math.hypot(dx, dy); + const ux = len > 1e-9 ? dx / len : 1, uy = len > 1e-9 ? dy / len : 0; + const nx = -uy * r, ny = ux * r; + if (len > 1e-9) { + this.tri(x1 + nx, y1 + ny, x1 - nx, y1 - ny, x2 - nx, y2 - ny, z); + this.tri(x1 + nx, y1 + ny, x2 - nx, y2 - ny, x2 + nx, y2 + ny, z); + } + const a0 = Math.atan2(ny, nx); + this.fan(x1, y1, r, a0, Math.PI, z); + this.fan(x2, y2, r, a0 + Math.PI, Math.PI, z); + } + fan(cx, cy, r, a0, sweep, z, n = SEG) { + for (let k = 0; k < n; k++) { + const a = a0 + (sweep * k) / n, b = a0 + (sweep * (k + 1)) / n; + this.tri(cx, cy, cx + r * Math.cos(a), cy + r * Math.sin(a), cx + r * Math.cos(b), cy + r * Math.sin(b), z); + } + } + disc(cx, cy, r, z, n = 20) { + this.fan(cx, cy, r, 0, Math.PI * 2, z, n); + } + /** polygon with optional holes; points are THREE.Vector2 */ + polygon(outer, holes, z) { + if (outer.length < 3) return; + const tris = THREE.ShapeUtils.triangulateShape(outer, holes || []); + const all = holes && holes.length ? outer.concat(...holes) : outer; + for (const [a, b, c] of tris) this.tri(all[a].x, all[a].y, all[b].x, all[b].y, all[c].x, all[c].y, z); + } + geometry() { + if (!this.pos.length) return null; + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3)); + g.computeVertexNormals(); + // flat parts face +Z; make sure normals are consistent even for CW input + const n = g.getAttribute('normal'); + for (let i = 0; i < n.count; i++) n.setXYZ(i, 0, 0, 1); + return g; + } +} + +const V = (p) => new THREE.Vector2(p.x, -p.y); + +function rotPt(x, y, deg) { + const a = (deg * Math.PI) / 180, c = Math.cos(a), s = Math.sin(a); + return [x * c - y * s, x * s + y * c]; +} + +/** Pad outline in flipped (three) space as an array of Vector2 */ +function padOutline(pad) { + const { w, h } = pad; + let local = []; + const shape = pad.shape; + if (shape === 'circle') { + const r = w / 2; + for (let k = 0; k < 24; k++) local.push([r * Math.cos((k / 24) * Math.PI * 2), r * Math.sin((k / 24) * Math.PI * 2)]); + } else if (shape === 'oval' || shape === 'roundrect') { + const rr = shape === 'oval' ? Math.min(w, h) / 2 : Math.min(w, h) * Math.min(0.5, pad.rratio ?? 0.25); + local = roundedRect(w, h, rr); + } else { + local = [[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]]; + } + // KiCad y-down → flip then rotate CCW by pad angle + return local.map(([x, y]) => { + const [rx, ry] = rotPt(x, -y, pad.rot || 0); + return new THREE.Vector2(pad.x + rx, -pad.y + ry); + }); +} + +function roundedRect(w, h, r) { + const pts = []; + if (r <= 1e-6) return [[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]]; + const cx = w / 2 - r, cy = h / 2 - r; + const corners = [[cx, cy, 0], [-cx, cy, 90], [-cx, -cy, 180], [cx, -cy, 270]]; + for (const [x, y, a0] of corners) { + for (let k = 0; k <= 6; k++) { + const a = ((a0 + (90 * k) / 6) * Math.PI) / 180; + pts.push([x + r * Math.cos(a), y + r * Math.sin(a)]); + } + } + return pts; +} + +function holeOutline(x, y, drill, rot, n = 20) { + // drill w/h slot → stadium; round → circle. Returned CW so it works as a hole. + const pts = []; + if (Math.abs(drill.w - drill.h) < 1e-6) { + const r = drill.w / 2; + for (let k = n - 1; k >= 0; k--) pts.push(new THREE.Vector2(x + r * Math.cos((k / n) * Math.PI * 2), -y + r * Math.sin((k / n) * Math.PI * 2))); + return pts; + } + const local = roundedRect(drill.w, drill.h, Math.min(drill.w, drill.h) / 2).reverse(); + for (const [lx, ly] of local) { + const [rx, ry] = rotPt(lx, -ly, rot || 0); + pts.push(new THREE.Vector2(x + rx, -y + ry)); + } + return pts; +} + +const ensureCCW = (pts) => (THREE.ShapeUtils.isClockWise(pts) ? pts.slice().reverse() : pts); +const ensureCW = (pts) => (THREE.ShapeUtils.isClockWise(pts) ? pts : pts.slice().reverse()); + +/** + * Build the board. Returns { group, layers: Map(name → Object3D), meta, setExplode(f), setMaskOpacity(o) }. + * opts: { maskColor, finish, silkColor, showComponents } + */ +export function buildBoardScene(board, opts = {}) { + const maskHex = MASK_COLORS[opts.maskColor] ?? MASK_COLORS.green; + const finishHex = FINISH_COLORS[opts.finish] ?? FINISH_COLORS.enig; + const silkHex = opts.silkColor === 'black' ? 0x151515 : 0xf4f4f0; + const T = board.thickness || 1.6; + const root = new THREE.Group(); + root.name = 'kicad-board'; + const boardGroup = new THREE.Group(); + root.add(boardGroup); + const layerGroups = new Map(); + const copper = board.copperLayers; + const nCu = copper.length; + + // Z position for each copper layer (evenly through the core, outer layers on the faces) + const layerZ = new Map(copper.map((name, i) => [name, nCu === 1 ? T : T - (T * i) / (nCu - 1)])); + + // ── substrate with outline cut-outs and drilled holes ── + const outer = ensureCCW(board.outline.outer.map(V)); + const shape = new THREE.Shape(outer); + for (const h of board.outline.holes) shape.holes.push(new THREE.Path(ensureCW(h.map(V)))); + const drillHoles = []; + for (const v of board.vias) drillHoles.push({ x: v.x, y: v.y, drill: { w: v.drill, h: v.drill }, rot: 0, plated: true }); + for (const p of board.pads) if (p.drill) drillHoles.push({ x: p.x, y: p.y, drill: p.drill, rot: p.rot, plated: p.type !== 'np_thru_hole' }); + const cutHoles = drillHoles.length <= 2500; + if (cutHoles) for (const d of drillHoles) shape.holes.push(new THREE.Path(holeOutline(d.x, d.y, d.drill, d.rot, d.drill.w > 1.2 ? 28 : 14))); + + const substrateGeo = new THREE.ExtrudeGeometry(shape, { depth: T, bevelEnabled: false, curveSegments: 12 }); + const maskMat = new THREE.MeshStandardMaterial({ color: maskHex, roughness: 0.45, metalness: 0.05, transparent: true, opacity: 1 }); + const fr4Mat = new THREE.MeshStandardMaterial({ color: FR4, roughness: 0.85, metalness: 0 }); + const substrate = new THREE.Mesh(substrateGeo, [maskMat, fr4Mat]); + substrate.name = 'substrate'; + boardGroup.add(substrate); + + // ── per-layer copper ── + const tint = new THREE.Color(maskHex).lerp(new THREE.Color(COPPER), 0.35); + const underMaskMat = new THREE.MeshStandardMaterial({ color: tint, roughness: 0.4, metalness: 0.2, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }); + const innerCuMat = new THREE.MeshStandardMaterial({ color: COPPER, roughness: 0.35, metalness: 0.6, side: THREE.DoubleSide }); + const padMat = new THREE.MeshStandardMaterial({ color: finishHex, roughness: 0.25, metalness: 0.85, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4 }); + const silkMat = new THREE.MeshStandardMaterial({ color: silkHex, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -6, polygonOffsetUnits: -6 }); + + const EPS = 0.012; + for (const name of copper) { + const isTop = name === 'F.Cu', isBot = name === 'B.Cu'; + const outerLayer = isTop || isBot; + const z0 = layerZ.get(name); + const face = isBot ? -1 : 1; + const zc = outerLayer ? z0 + face * EPS : z0; + const cu = new TriBuffer(); + for (const t of board.tracks) { + if (t.layer !== name) continue; + for (let k = 0; k + 1 < t.pts.length; k++) cu.capsule(t.pts[k].x, -t.pts[k].y, t.pts[k + 1].x, -t.pts[k + 1].y, t.width, zc); + } + for (const zn of board.zones) { + if (zn.layer !== name || zn.pts.length < 3) continue; + cu.polygon(zn.pts.map(V), [], zc); + } + // via annular rings on every layer they span + for (const v of board.vias) { + const a = copper.indexOf(v.from), b = copper.indexOf(v.to), i = copper.indexOf(name); + if (i >= Math.min(a, b) && i <= Math.max(a, b)) cu.disc(v.x, -v.y, v.size / 2, zc, 16); + } + const g = new THREE.Group(); + g.name = name; + g.userData.baseZ = 0; + const cuGeo = cu.geometry(); + if (cuGeo) { + if (isBot) flipNormals(cuGeo); + g.add(new THREE.Mesh(cuGeo, outerLayer ? underMaskMat : innerCuMat)); + } + // exposed pads (outer layers) — sit on top of the mask + const pads = new TriBuffer(); + const zp = outerLayer ? z0 + face * EPS * 2 : z0 + 0.001; + for (const p of board.pads) { + if (!p.layers.includes(name)) continue; + if (p.type === 'np_thru_hole') continue; + const holes = p.drill ? [holeOutline(p.x, p.y, p.drill, p.rot, 14)] : []; + pads.polygon(ensureCCW(padOutline(p)), holes, zp); + } + const padGeo = pads.geometry(); + if (padGeo) { + if (isBot) flipNormals(padGeo); + g.add(new THREE.Mesh(padGeo, outerLayer ? padMat : innerCuMat)); + } + layerGroups.set(name, g); + boardGroup.add(g); + } + + // ── plated barrels for vias & THT pads ── + const barrels = drillHoles.filter((d) => d.plated && Math.abs(d.drill.w - d.drill.h) < 1e-6); + if (barrels.length) { + const cyl = new THREE.CylinderGeometry(0.5, 0.5, T, 16, 1, true); + cyl.rotateX(Math.PI / 2); + cyl.translate(0, 0, T / 2); + const barrelMat = new THREE.MeshStandardMaterial({ color: finishHex, roughness: 0.3, metalness: 0.9, side: THREE.DoubleSide }); + const inst = new THREE.InstancedMesh(cyl, barrelMat, barrels.length); + const m = new THREE.Matrix4(); + barrels.forEach((d, i) => { + m.makeScale(d.drill.w, d.drill.w, 1).setPosition(d.x, -d.y, 0); + inst.setMatrixAt(i, m); + }); + inst.name = 'barrels'; + boardGroup.add(inst); + } + if (!cutHoles) { + // too many holes to boolean-cut: paint them as dark discs instead + for (const zf of [T + EPS * 3, -EPS * 3]) { + const dots = new TriBuffer(); + for (const d of drillHoles) dots.disc(d.x, -d.y, d.drill.w / 2, zf, 10); + const geo = dots.geometry(); + if (geo) boardGroup.add(new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x050505, side: THREE.DoubleSide }))); + } + } + + // ── silkscreen ── + for (const [layer, z, bottom] of [['F.SilkS', T + EPS * 3, false], ['B.SilkS', -EPS * 3, true]]) { + const silk = new TriBuffer(); + for (const gp of board.graphics) { + if (gp.layer !== layer && gp.layer !== layer.replace('SilkS', 'Silkscreen')) continue; + const pts = gp.closed ? gp.pts.concat([gp.pts[0]]) : gp.pts; + if (gp.filled && gp.closed && gp.pts.length >= 3) silk.polygon(ensureCCW(gp.pts.map(V)), [], z); + for (let k = 0; k + 1 < pts.length; k++) silk.capsule(pts[k].x, -pts[k].y, pts[k + 1].x, -pts[k + 1].y, Math.max(gp.width, 0.1), z); + } + const geo = silk.geometry(); + if (geo) { + if (bottom) flipNormals(geo); + const mesh = new THREE.Mesh(geo, silkMat); + mesh.name = layer; + layerGroups.set(layer, mesh); + boardGroup.add(mesh); + } + } + + // ── component bodies (approximate boxes from the fab/courtyard outline) ── + const components = new THREE.Group(); + components.name = 'components'; + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x1b1c1f, roughness: 0.55, metalness: 0.1 }); + const passiveMat = new THREE.MeshStandardMaterial({ color: 0x6b5a45, roughness: 0.6, metalness: 0.05 }); + for (const fp of board.footprints) { + if (/MountingHole|Fiducial|TestPoint|Logo|NetTie|Jumper/i.test(fp.lib) || fp.padCount === 0) continue; + const bw = fp.local.maxX - fp.local.minX, bh = fp.local.maxY - fp.local.minY; + if (bw <= 0 || bh <= 0) continue; + const passive = /^(C|R|L|FB)\d/.test(fp.ref) || /_0[24-8]0[0-9]_/.test(fp.lib); + const height = passive ? Math.min(0.9, Math.max(0.35, Math.min(bw, bh) * 0.55)) : Math.min(4, Math.max(0.8, Math.min(bw, bh) * 0.18 + 0.6)); + const box = new THREE.BoxGeometry(bw * (passive ? 0.7 : 1), bh * (passive ? 0.95 : 1), height); + const mesh = new THREE.Mesh(box, passive ? passiveMat : bodyMat); + const cx = (fp.local.minX + fp.local.maxX) / 2, cy = (fp.local.minY + fp.local.maxY) / 2; + const [ox, oy] = rotPt(cx, -cy, fp.rot); + const onBottom = fp.side === 'B'; + mesh.position.set(fp.x + ox, -fp.y + oy, onBottom ? -height / 2 - EPS * 4 : T + height / 2 + EPS * 4); + mesh.rotation.z = (fp.rot * Math.PI) / 180; + mesh.userData = { ref: fp.ref, value: fp.value, lib: fp.lib, baseZ: mesh.position.z }; + components.add(mesh); + } + components.visible = opts.showComponents !== false; + boardGroup.add(components); + + // centre the board at the origin and lay it flat (Y-up world) + const cx = (board.bbox.minX + board.bbox.maxX) / 2, cy = -(board.bbox.minY + board.bbox.maxY) / 2; + boardGroup.position.set(-cx, -cy, -T / 2); + root.rotation.x = -Math.PI / 2; + + function setExplode(f) { + // separate the copper layers vertically so inner layers can be inspected + copper.forEach((name, i) => { + const g = layerGroups.get(name); + if (!g) return; + const offset = (nCu - 1) / 2 - i; + g.position.z = offset * f * 6; + }); + const top = layerGroups.get('F.SilkS'); + const bot = layerGroups.get('B.SilkS'); + if (top) top.position.z = ((nCu - 1) / 2) * f * 6; + if (bot) bot.position.z = (-(nCu - 1) / 2) * f * 6; + components.children.forEach((c) => { + c.position.z = c.userData.baseZ + Math.sign(c.userData.baseZ) * ((nCu - 1) / 2) * f * 6; + }); + } + + function setMaskOpacity(o) { + maskMat.opacity = o; + maskMat.depthWrite = o > 0.95; + fr4Mat.transparent = o < 1; + fr4Mat.opacity = o; + fr4Mat.depthWrite = o > 0.95; + } + + // ── net highlight overlay ── + const highlightMat = new THREE.MeshBasicMaterial({ color: 0x2ce5e5, side: THREE.DoubleSide, transparent: true, opacity: 0.92, polygonOffset: true, polygonOffsetFactor: -10, polygonOffsetUnits: -10 }); + let highlightMeshes = []; + function clearHighlight() { + for (const m of highlightMeshes) { + m.parent?.remove(m); + m.geometry.dispose(); + } + highlightMeshes = []; + } + function highlightNet(net) { + clearHighlight(); + if (!net) return 0; + let count = 0; + for (const name of copper) { + const g = layerGroups.get(name); + if (!g) continue; + const isBot = name === 'B.Cu'; + const outerLayer = name === 'F.Cu' || isBot; + const z = layerZ.get(name) + (outerLayer ? (isBot ? -1 : 1) * EPS * 3.5 : 0.002); + const tb = new TriBuffer(); + for (const t of board.tracks) { + if (t.layer !== name || t.net !== net) continue; + for (let k = 0; k + 1 < t.pts.length; k++) tb.capsule(t.pts[k].x, -t.pts[k].y, t.pts[k + 1].x, -t.pts[k + 1].y, t.width, z); + count++; + } + for (const zn of board.zones) if (zn.layer === name && zn.net === net && zn.pts.length >= 3) tb.polygon(zn.pts.map(V), [], z); + for (const p of board.pads) { + if (p.net !== net || !p.layers.includes(name) || p.type === 'np_thru_hole') continue; + tb.polygon(ensureCCW(padOutline(p)), p.drill ? [holeOutline(p.x, p.y, p.drill, p.rot, 14)] : [], z); + count++; + } + for (const v of board.vias) { + if (v.net !== net) continue; + const a = copper.indexOf(v.from), b = copper.indexOf(v.to), i = copper.indexOf(name); + if (i >= Math.min(a, b) && i <= Math.max(a, b)) tb.disc(v.x, -v.y, v.size / 2, z, 16); + } + const geo = tb.geometry(); + if (!geo) continue; + const mesh = new THREE.Mesh(geo, highlightMat); + mesh.renderOrder = 10; + g.add(mesh); + highlightMeshes.push(mesh); + } + return count; + } + + // ── hit testing in KiCad file coordinates ── + const netStats = new Map(); + for (const t of board.tracks) { + if (!t.net) continue; + const s = netStats.get(t.net) || { length: 0, tracks: 0, pads: 0, vias: 0 }; + for (let k = 0; k + 1 < t.pts.length; k++) s.length += Math.hypot(t.pts[k + 1].x - t.pts[k].x, t.pts[k + 1].y - t.pts[k].y); + s.tracks++; + netStats.set(t.net, s); + } + for (const p of board.pads) if (p.net) { const s = netStats.get(p.net) || { length: 0, tracks: 0, pads: 0, vias: 0 }; s.pads++; netStats.set(p.net, s); } + for (const v of board.vias) if (v.net) { const s = netStats.get(v.net) || { length: 0, tracks: 0, pads: 0, vias: 0 }; s.vias++; netStats.set(v.net, s); } + + const segDist = (px, py, a, b) => { + const dx = b.x - a.x, dy = b.y - a.y; + const l2 = dx * dx + dy * dy; + const t = l2 ? Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / l2)) : 0; + return Math.hypot(px - (a.x + t * dx), py - (a.y + t * dy)); + }; + const inPoly = (px, py, pts) => { + let inside = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const a = pts[i], b = pts[j]; + if ((a.y > py) !== (b.y > py) && px < ((b.x - a.x) * (py - a.y)) / (b.y - a.y) + a.x) inside = !inside; + } + return inside; + }; + + /** Identify the copper / component under (x, y) on the given side ('F' | 'B'). */ + function pick(x, y, side = 'F') { + const layer = side === 'B' ? 'B.Cu' : 'F.Cu'; + for (const p of board.pads) { + if (!p.layers.includes(layer) && !p.drill) continue; + const a = ((p.rot || 0) * Math.PI) / 180, c = Math.cos(a), s = Math.sin(a); + const dx = x - p.x, dy = y - p.y; + const lx = c * dx - s * dy, ly = s * dx + c * dy; + const hit = p.shape === 'circle' ? Math.hypot(dx, dy) <= p.w / 2 : Math.abs(lx) <= p.w / 2 && Math.abs(ly) <= p.h / 2; + if (hit) return { kind: 'pad', net: p.net, footprint: p.footprint, pad: p, layer }; + } + for (const v of board.vias) if (Math.hypot(x - v.x, y - v.y) <= v.size / 2) return { kind: 'via', net: v.net, via: v }; + for (const t of board.tracks) { + if (t.layer !== layer) continue; + for (let k = 0; k + 1 < t.pts.length; k++) if (segDist(x, y, t.pts[k], t.pts[k + 1]) <= t.width / 2) return { kind: 'track', net: t.net, track: t, layer }; + } + for (const f of board.footprints) if (f.side === side && inPoly(x, y, f.corners)) return { kind: 'footprint', footprint: f.ref, fp: f }; + for (const z of board.zones) if (z.layer === layer && inPoly(x, y, z.pts)) return { kind: 'zone', net: z.net, layer }; + return null; + } + + /** World point → KiCad file coordinates. */ + function toBoard(worldPoint) { + const p = boardGroup.worldToLocal(worldPoint.clone()); + return { x: p.x, y: -p.y }; + } + + return { + board, + highlightNet, + clearHighlight, + pick, + toBoard, + netStats, + thickness: T, + object: root, + layers: layerGroups, + components, + setExplode, + setMaskOpacity, + size: { x: board.bbox.maxX - board.bbox.minX, y: board.bbox.maxY - board.bbox.minY, z: T }, + }; +} + +function flipNormals(geo) { + const n = geo.getAttribute('normal'); + for (let i = 0; i < n.count; i++) n.setXYZ(i, 0, 0, -1); + // swap winding so the face is front-facing from below + const p = geo.getAttribute('position'); + for (let i = 0; i < p.count; i += 3) { + const bx = p.getX(i + 1), by = p.getY(i + 1), bz = p.getZ(i + 1); + p.setXYZ(i + 1, p.getX(i + 2), p.getY(i + 2), p.getZ(i + 2)); + p.setXYZ(i + 2, bx, by, bz); + } +} + diff --git a/pcb-portal/public/js/viewer/viewer.js b/pcb-portal/public/js/viewer/viewer.js new file mode 100644 index 0000000..79f6f40 --- /dev/null +++ b/pcb-portal/public/js/viewer/viewer.js @@ -0,0 +1,584 @@ +// QodeX 3D board viewer — KiCad (.kicad_pcb), glTF (.glb/.gltf), STL, OBJ, VRML (.wrl). +// +// const v = createViewer(containerEl, { board: { maskColor: 'black', finish: 'enig' } }); +// await v.loadUrl('/api/files/abc', 'board.kicad_pcb'); // or v.loadFile(File) +// v.dispose(); +// +// Tools: inspect (hover + click to highlight a whole net), measure (two clicks), +// layer visibility, solder-mask transparency, exploded stack, auto-rotate. +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; +import { parseKiCadPcb } from './kicad-parser.js'; +import { buildBoardScene } from './kicad-scene.js'; + +export const VIEWABLE_EXTENSIONS = ['kicad_pcb', 'glb', 'gltf', 'stl', 'obj', 'wrl']; + +export function viewerKindFor(name) { + const ext = String(name || '').toLowerCase().split('.').pop(); + return VIEWABLE_EXTENSIONS.includes(ext) ? ext : null; +} + +const LAYER_SWATCH = { 'F.Cu': '#e0543f', 'In1.Cu': '#c9b43a', 'In2.Cu': '#3ec96a', 'In3.Cu': '#3aa7c9', 'In4.Cu': '#9b6ad6', 'B.Cu': '#3f7de0', 'F.SilkS': '#f4f4f0', 'B.SilkS': '#b8b8c8' }; + +function el(tag, cls, text) { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text !== undefined) n.textContent = text; + return n; +} + +export function createViewer(container, options = {}) { + container.classList.add('qx-viewer'); + container.replaceChildren(); + + const canvasWrap = el('div', 'qx-viewer__canvas'); + container.appendChild(canvasWrap); + + const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true, alpha: !!options.transparent }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.0; + canvasWrap.appendChild(renderer.domElement); + renderer.domElement.setAttribute('aria-label', '3D board view'); + + const scene = new THREE.Scene(); + if (!options.transparent) scene.background = new THREE.Color(options.background ?? 0x0b0d10); + const pmrem = new THREE.PMREMGenerator(renderer); + scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; + + const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 10000); + camera.position.set(60, 70, 90); + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.screenSpacePanning = true; + controls.autoRotate = !!options.autoRotate; + controls.autoRotateSpeed = 0.8; + + scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 0.55)); + const key = new THREE.DirectionalLight(0xffffff, 1.5); + key.position.set(1, 2, 1.2); + scene.add(key); + const rim = new THREE.DirectionalLight(0xffb076, 0.45); + rim.position.set(-1.5, 0.6, -1); + scene.add(rim); + + const grid = new THREE.GridHelper(400, 80, 0x1f2630, 0x141820); + grid.material.transparent = true; + grid.material.opacity = options.hideGrid ? 0 : 0.55; + scene.add(grid); + + // measurement graphics + const measureGroup = new THREE.Group(); + scene.add(measureGroup); + const markerMat = new THREE.MeshBasicMaterial({ color: 0xffb076, depthTest: false }); + const lineMat = new THREE.LineBasicMaterial({ color: 0xffb076, depthTest: false }); + + // ── overlay UI ── + const overlay = el('div', 'qx-viewer__overlay'); + const status = el('div', 'qx-viewer__status'); + const toolbar = el('div', 'qx-viewer__toolbar'); + const panel = el('div', 'qx-viewer__panel'); + const tip = el('div', 'qx-viewer__tip'); + const inspectBox = el('div', 'qx-viewer__inspect'); + const hint = el('div', 'qx-viewer__hint'); + panel.hidden = true; + tip.hidden = true; + inspectBox.hidden = true; + hint.hidden = true; + container.append(overlay, status, toolbar, panel, tip, inspectBox, hint); + if (options.minimalUi) toolbar.hidden = true; + + let current = null; // { object, kind, board?, boardScene? } + let fitRadius = 50; + let disposed = false; + let mode = 'orbit'; // orbit | inspect | measure + const measurePts = []; + + const group = () => { + const g = el('div', 'qx-viewer__group'); + toolbar.appendChild(g); + return g; + }; + function button(g, label, onClick, title) { + const b = el('button', 'qx-viewer__btn', label); + b.type = 'button'; + b.title = title || label; + b.addEventListener('click', onClick); + g.appendChild(b); + return b; + } + const gView = group(); + button(gView, 'Iso', () => setView('iso'), 'Isometric view'); + button(gView, 'Top', () => setView('top'), 'Top view'); + button(gView, 'Bottom', () => setView('bottom'), 'Bottom view'); + const gTools = group(); + const inspectBtn = button(gTools, 'Inspect', () => setMode(mode === 'inspect' ? 'orbit' : 'inspect'), 'Hover to identify copper; click to highlight a net'); + const measureBtn = button(gTools, 'Measure', () => setMode(mode === 'measure' ? 'orbit' : 'measure'), 'Click two points to measure distance'); + const layersBtn = button(gTools, 'Layers', () => { + panel.hidden = !panel.hidden; + layersBtn.classList.toggle('is-on', !panel.hidden); + }); + const gMisc = group(); + const spinBtn = button(gMisc, 'Rotate', () => { + controls.autoRotate = !controls.autoRotate; + spinBtn.classList.toggle('is-on', controls.autoRotate); + }, 'Auto-rotate'); + spinBtn.classList.toggle('is-on', controls.autoRotate); + button(gMisc, 'PNG', screenshot, 'Save a screenshot'); + button(gMisc, 'Full', () => { + if (document.fullscreenElement) document.exitFullscreen(); + else container.requestFullscreen?.(); + }, 'Fullscreen'); + + function setMode(m) { + mode = m; + inspectBtn.classList.toggle('is-on', m === 'inspect'); + measureBtn.classList.toggle('is-on', m === 'measure'); + container.classList.toggle('is-inspecting', m === 'inspect'); + container.classList.toggle('is-measuring', m === 'measure'); + tip.hidden = true; + if (m !== 'measure') clearMeasure(); + hint.hidden = m === 'orbit'; + hint.textContent = m === 'measure' ? 'Click two points on the model · Esc to exit' : m === 'inspect' ? 'Hover to identify · click to highlight the net · Esc to clear' : ''; + if (m === 'orbit') clearSelection(); + } + + function resize() { + const w = canvasWrap.clientWidth || 300, h = canvasWrap.clientHeight || 300; + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + } + const ro = new ResizeObserver(resize); + ro.observe(canvasWrap); + resize(); + + function loop() { + if (disposed) return; + controls.update(); + renderer.render(scene, camera); + requestAnimationFrame(loop); + } + requestAnimationFrame(loop); + + function setView(which) { + const r = fitRadius; + const d = (r / Math.sin((camera.fov * Math.PI) / 360)) * 1.02; + controls.target.set(0, 0, 0); + if (which === 'top') camera.position.set(0, d, 0.001); + else if (which === 'bottom') camera.position.set(0, -d, 0.001); + else camera.position.set(d * 0.42, d * 0.6, d * 0.68); + camera.near = Math.max(0.01, d / 1000); + camera.far = d * 20; + camera.updateProjectionMatrix(); + controls.update(); + } + + function frame(object) { + const box = new THREE.Box3().setFromObject(object); + const sphere = box.getBoundingSphere(new THREE.Sphere()); + fitRadius = Math.max(sphere.radius, 0.001); + grid.position.y = box.min.y - fitRadius * 0.04; + grid.scale.setScalar(Math.max(0.05, fitRadius / 20)); + setView('iso'); + } + + function clear() { + panel.hidden = true; + panel.replaceChildren(); + overlay.replaceChildren(); + clearSelection(); + clearMeasure(); + if (!current) return; + scene.remove(current.object); + current.object.traverse((o) => { + o.geometry?.dispose?.(); + const mats = Array.isArray(o.material) ? o.material : o.material ? [o.material] : []; + mats.forEach((m) => { + m.map?.dispose?.(); + m.dispose?.(); + }); + }); + current = null; + } + + function setStatus(text, isError = false) { + status.textContent = text || ''; + status.hidden = !text; + status.classList.toggle('is-error', !!isError); + } + + function showStats(rows) { + const dl = el('dl'); + for (const [k, v] of rows) dl.append(el('dt', null, k), el('dd', null, v)); + overlay.replaceChildren(dl); + } + + // ── picking ── + const raycaster = new THREE.Raycaster(); + const ndc = new THREE.Vector2(); + function pointerRay(ev) { + const r = renderer.domElement.getBoundingClientRect(); + ndc.set(((ev.clientX - r.left) / r.width) * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1); + raycaster.setFromCamera(ndc, camera); + return r; + } + /** Surface point under the pointer: the board face for KiCad, mesh hit otherwise. */ + function surfacePoint(ev) { + pointerRay(ev); + if (current?.kind === 'kicad_pcb') { + const fromBelow = camera.position.y < 0; + const y = (fromBelow ? -1 : 1) * (current.boardScene.thickness / 2); + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -y); + const hit = raycaster.ray.intersectPlane(plane, new THREE.Vector3()); + return hit ? { point: hit, side: fromBelow ? 'B' : 'F' } : null; + } + if (!current) return null; + const hits = raycaster.intersectObject(current.object, true); + return hits.length ? { point: hits[0].point, object: hits[0].object } : null; + } + + function describe(hit) { + if (!hit) return null; + switch (hit.kind) { + case 'pad': + return { title: `${hit.footprint || '?'} · pad ${hit.pad.number ?? ''}`.trim(), net: hit.net, rows: [['Type', hit.pad.type], ['Shape', hit.pad.shape], ['Size', `${hit.pad.w} × ${hit.pad.h} mm`], ...(hit.pad.drill ? [['Drill', `${hit.pad.drill.w} mm`]] : [])] }; + case 'via': + return { title: 'Via', net: hit.net, rows: [['Size / drill', `${hit.via.size} / ${hit.via.drill} mm`], ['Span', `${hit.via.from} → ${hit.via.to}`], ['Type', hit.via.type]] }; + case 'track': + return { title: `Track on ${hit.layer}`, net: hit.net, rows: [['Width', `${hit.track.width} mm`]] }; + case 'zone': + return { title: `Copper pour on ${hit.layer}`, net: hit.net, rows: [] }; + case 'footprint': + return { title: hit.fp.ref || 'Footprint', net: null, rows: [['Value', hit.fp.value || '—'], ['Footprint', (hit.fp.lib || '').split(':').pop()], ['Side', hit.fp.side === 'B' ? 'Bottom' : 'Top'], ['Pads', String(hit.fp.padCount)]] }; + default: + return null; + } + } + + let hoverQueued = false; + let lastMove = null; + renderer.domElement.addEventListener('pointermove', (ev) => { + lastMove = ev; + if (hoverQueued || mode === 'orbit') return; + hoverQueued = true; + requestAnimationFrame(() => { + hoverQueued = false; + const e = lastMove; + if (mode === 'measure' || !current) { + tip.hidden = true; + return; + } + const s = surfacePoint(e); + let text = null; + if (s && current.kind === 'kicad_pcb') { + const b = current.boardScene.toBoard(s.point); + const d = describe(current.boardScene.pick(b.x, b.y, s.side)); + if (d) text = [d.title, d.net ? `net ${d.net}` : null]; + } else if (s?.object) { + text = [s.object.name || s.object.parent?.name || 'Mesh', null]; + } + if (!text) { + tip.hidden = true; + return; + } + const r = container.getBoundingClientRect(); + tip.replaceChildren(el('b', null, text[0])); + if (text[1]) tip.append(document.createTextNode(' '), el('i', null, text[1])); + tip.style.left = `${e.clientX - r.left}px`; + tip.style.top = `${e.clientY - r.top}px`; + tip.hidden = false; + }); + }); + renderer.domElement.addEventListener('pointerleave', () => { tip.hidden = true; }); + + // distinguish clicks from orbit drags + let downAt = null; + renderer.domElement.addEventListener('pointerdown', (ev) => { downAt = [ev.clientX, ev.clientY]; }); + renderer.domElement.addEventListener('pointerup', (ev) => { + if (!downAt || Math.hypot(ev.clientX - downAt[0], ev.clientY - downAt[1]) > 4) return; + downAt = null; + if (mode === 'measure') onMeasureClick(ev); + else if (mode === 'inspect') onInspectClick(ev); + }); + const onKey = (e) => { + if (e.key === 'Escape' && mode !== 'orbit') setMode('orbit'); + }; + window.addEventListener('keydown', onKey); + + function clearSelection() { + inspectBox.hidden = true; + current?.boardScene?.clearHighlight(); + } + + function onInspectClick(ev) { + if (!current) return; + const s = surfacePoint(ev); + if (!s) return clearSelection(); + if (current.kind !== 'kicad_pcb') { + showInspect({ title: s.object?.name || 'Mesh', net: null, rows: [['Triangles', String(triCount(s.object))]] }); + return; + } + const b = current.boardScene.toBoard(s.point); + const d = describe(current.boardScene.pick(b.x, b.y, s.side)); + if (!d) return clearSelection(); + current.boardScene.clearHighlight(); + const rows = [...d.rows, ['Position', `${b.x.toFixed(2)}, ${b.y.toFixed(2)}`]]; + if (d.net) { + current.boardScene.highlightNet(d.net); + const st = current.boardScene.netStats.get(d.net); + if (st) rows.push(['Net pads / vias', `${st.pads} / ${st.vias}`], ['Routed length', `${st.length.toFixed(2)} mm`]); + } + showInspect(d, rows); + } + + function showInspect(d, rows = d.rows) { + const h = el('h5', null, d.net ? `Net · ${d.net}` : 'Selection'); + const close = el('button', null, '×'); + close.type = 'button'; + close.title = 'Clear selection'; + close.addEventListener('click', clearSelection); + h.append(close); + const dl = el('dl'); + dl.append(el('dt', null, 'Item'), el('dd', null, d.title)); + for (const [k, v] of rows) dl.append(el('dt', null, k), el('dd', null, v)); + inspectBox.replaceChildren(h, dl); + inspectBox.hidden = false; + } + + function triCount(obj) { + const g = obj?.geometry; + if (!g) return 0; + return Math.round(g.index ? g.index.count / 3 : g.getAttribute('position').count / 3); + } + + function onMeasureClick(ev) { + const s = surfacePoint(ev); + if (!s) return; + if (measurePts.length === 2) clearMeasure(); + measurePts.push(s.point.clone()); + const size = fitRadius * 0.008; + const dot = new THREE.Mesh(new THREE.SphereGeometry(size, 12, 8), markerMat); + dot.position.copy(s.point); + dot.renderOrder = 20; + measureGroup.add(dot); + if (measurePts.length === 2) { + const geo = new THREE.BufferGeometry().setFromPoints(measurePts); + const line = new THREE.Line(geo, lineMat); + line.renderOrder = 20; + measureGroup.add(line); + const [a, b] = measurePts; + let dx, dy; + if (current?.kind === 'kicad_pcb') { + const pa = current.boardScene.toBoard(a), pb = current.boardScene.toBoard(b); + dx = Math.abs(pb.x - pa.x); + dy = Math.abs(pb.y - pa.y); + } else { + dx = Math.abs(b.x - a.x); + dy = Math.abs(b.z - a.z); + } + const dist = a.distanceTo(b); + showInspect({ title: 'Measurement', net: null, rows: [] }, [['Distance', `${dist.toFixed(3)} mm`], ['ΔX', `${dx.toFixed(3)} mm`], ['ΔY', `${dy.toFixed(3)} mm`], ['In mils', `${(dist / 0.0254).toFixed(1)} mil`]]); + } + } + + function clearMeasure() { + measurePts.length = 0; + for (const c of [...measureGroup.children]) { + measureGroup.remove(c); + c.geometry?.dispose(); + } + if (mode === 'measure') inspectBox.hidden = true; + } + + // ── layer panel ── + function buildKiCadPanel(bs, board) { + panel.replaceChildren(); + panel.append(el('h4', null, 'Copper & silk')); + const addToggle = (label, obj, checked = true, swatch) => { + const row = el('label', 'qx-viewer__row'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = checked; + obj.visible = checked; + cb.addEventListener('change', () => { obj.visible = cb.checked; }); + row.append(cb); + if (swatch) { + const sw = el('span', 'qx-viewer__swatch'); + sw.style.background = swatch; + row.append(sw); + } + row.append(el('span', null, label)); + panel.appendChild(row); + }; + for (const name of [...board.copperLayers, 'F.SilkS', 'B.SilkS']) { + const g = bs.layers.get(name); + if (g) addToggle(name, g, true, LAYER_SWATCH[name] || '#888'); + } + panel.append(el('h4', null, 'Assembly')); + addToggle('Components', bs.components, bs.components.visible); + + const slider = (label, min, max, step, value, onInput) => { + const row = el('label', 'qx-viewer__row qx-viewer__row--slider'); + const input = document.createElement('input'); + input.type = 'range'; + input.min = min; + input.max = max; + input.step = step; + input.value = value; + input.addEventListener('input', () => onInput(parseFloat(input.value))); + row.append(el('span', null, label), input); + panel.appendChild(row); + return input; + }; + panel.append(el('h4', null, 'Stack')); + slider('Solder-mask opacity', 0, 1, 0.01, 1, (v) => bs.setMaskOpacity(v)); + slider('Layer explode', 0, 1, 0.01, 0, (v) => bs.setExplode(v)); + } + + async function loadKiCad(text, opts = {}) { + const board = parseKiCadPcb(text); + const bs = buildBoardScene(board, { ...options.board, ...opts }); + scene.add(bs.object); + current = { object: bs.object, kind: 'kicad_pcb', board, boardScene: bs }; + frame(bs.object); + buildKiCadPanel(bs, board); + layersBtn.hidden = false; + inspectBtn.hidden = false; + const s = board.stats; + showStats([ + ['Size', `${s.widthMm} × ${s.heightMm} mm`], + ['Copper layers', String(s.copperLayerCount)], + ['Thickness', `${s.thickness} mm`], + ['Footprints', String(s.footprints)], + ['Pads', String(s.pads)], + ['Vias', String(s.vias)], + ['Nets', String(s.nets)], + ['KiCad', s.kicadVersion], + ]); + return board; + } + + async function loadMesh(kind, buffer) { + let object; + if (kind === 'glb' || kind === 'gltf') { + const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js'); + const gltf = await new Promise((res, rej) => new GLTFLoader().parse(buffer, '', res, rej)); + object = gltf.scene || gltf.scenes?.[0]; + } else if (kind === 'stl') { + const { STLLoader } = await import('three/addons/loaders/STLLoader.js'); + const geo = new STLLoader().parse(buffer); + geo.computeVertexNormals(); + const mat = new THREE.MeshStandardMaterial({ color: geo.hasAttribute('color') ? 0xffffff : 0x2f7a4c, vertexColors: geo.hasAttribute('color'), roughness: 0.5, metalness: 0.2 }); + object = new THREE.Mesh(geo, mat); + object.rotation.x = -Math.PI / 2; + } else if (kind === 'obj') { + const { OBJLoader } = await import('three/addons/loaders/OBJLoader.js'); + object = new OBJLoader().parse(new TextDecoder().decode(buffer)); + } else if (kind === 'wrl') { + const { VRMLLoader } = await import('three/addons/loaders/VRMLLoader.js'); + object = new VRMLLoader().parse(new TextDecoder().decode(buffer), ''); + object.rotation.x = -Math.PI / 2; // KiCad VRML export is Z-up + } else { + throw new Error(`Unsupported format: ${kind}`); + } + const wrapper = new THREE.Group(); + wrapper.add(object); + const box = new THREE.Box3().setFromObject(wrapper); + object.position.sub(box.getCenter(new THREE.Vector3())); + scene.add(wrapper); + current = { object: wrapper, kind }; + frame(wrapper); + layersBtn.hidden = true; + inspectBtn.hidden = false; + let tris = 0, meshes = 0; + wrapper.traverse((o) => { + if (o.isMesh) { + meshes++; + tris += triCount(o); + } + }); + const size = box.getSize(new THREE.Vector3()); + showStats([ + ['Format', kind.toUpperCase()], + ['Bounds', `${size.x.toFixed(1)} × ${size.z.toFixed(1)} × ${size.y.toFixed(1)}`], + ['Meshes', String(meshes)], + ['Triangles', tris.toLocaleString('en-US')], + ]); + return object; + } + + async function loadBuffer(buffer, name, opts) { + const kind = viewerKindFor(name); + if (!kind) throw new Error(`This format cannot be shown in 3D: ${name}`); + clear(); + setStatus('Building 3D model…'); + // let the status paint before heavy synchronous work + await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0))); + try { + if (kind === 'kicad_pcb') await loadKiCad(new TextDecoder().decode(buffer), opts); + else await loadMesh(kind, buffer); + setStatus(''); + options.onLoad?.(current); + } catch (err) { + console.error(err); + setStatus(`Could not display this file: ${err?.message || err}`, true); + throw err; + } + } + + async function loadUrl(url, name, opts) { + setStatus('Downloading…'); + const res = await fetch(url, { credentials: 'same-origin' }); + if (!res.ok) { + setStatus(`Download failed (HTTP ${res.status})`, true); + throw new Error(`HTTP ${res.status}`); + } + return loadBuffer(await res.arrayBuffer(), name, opts); + } + + async function loadFile(file, opts) { + return loadBuffer(await file.arrayBuffer(), file.name, opts); + } + + function screenshot() { + renderer.render(scene, camera); + const a = document.createElement('a'); + a.href = renderer.domElement.toDataURL('image/png'); + a.download = 'qodex-board.png'; + a.click(); + } + + function dispose() { + disposed = true; + ro.disconnect(); + window.removeEventListener('keydown', onKey); + clear(); + controls.dispose(); + pmrem.dispose(); + renderer.dispose(); + container.replaceChildren(); + } + + layersBtn.hidden = true; + inspectBtn.hidden = true; + setStatus(''); + return { + loadUrl, + loadFile, + loadBuffer, + dispose, + setView, + setMode, + controls, + get current() { + return current; + }, + renderer, + scene, + camera, + }; +} diff --git a/pcb-portal/public/og-image.svg b/pcb-portal/public/og-image.svg new file mode 100644 index 0000000..0c6e2e7 --- /dev/null +++ b/pcb-portal/public/og-image.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + Intent in. + Evidence out. + Autonomous KiCad PCB routing · DFM polish · 3D Studio + QodeX PCB + diff --git a/pcb-portal/public/samples/demo-board.kicad_pcb b/pcb-portal/public/samples/demo-board.kicad_pcb new file mode 100644 index 0000000..08531ff --- /dev/null +++ b/pcb-portal/public/samples/demo-board.kicad_pcb @@ -0,0 +1,287 @@ +(kicad_pcb + (version 20240108) + (generator "qodex_demo") + (generator_version "8.0") + (general (thickness 1.6) (legacy_teardrops no)) + (paper "A4") + (layers + (0 "F.Cu" signal) + (1 "In1.Cu" power) + (2 "In2.Cu" signal) + (31 "B.Cu" signal) + (36 "B.SilkS" user "B.Silkscreen") + (37 "F.SilkS" user "F.Silkscreen") + (38 "B.Mask" user) + (39 "F.Mask" user) + (44 "Edge.Cuts" user) + (46 "B.CrtYd" user "B.Courtyard") + (47 "F.CrtYd" user "F.Courtyard") + (48 "B.Fab" user) + (49 "F.Fab" user) + ) + (setup + (stackup + (layer "F.SilkS" (type "Top Silk Screen")) + (layer "F.Mask" (type "Top Solder Mask") (color "Green") (thickness 0.01)) + (layer "F.Cu" (type "copper") (thickness 0.035)) + (layer "dielectric 1" (type "prepreg") (thickness 0.2104) (material "FR4") (epsilon_r 4.4)) + (layer "In1.Cu" (type "copper") (thickness 0.0152)) + (layer "dielectric 2" (type "core") (thickness 1.065) (material "FR4") (epsilon_r 4.6)) + (layer "In2.Cu" (type "copper") (thickness 0.0152)) + (layer "dielectric 3" (type "prepreg") (thickness 0.2104) (material "FR4") (epsilon_r 4.4)) + (layer "B.Cu" (type "copper") (thickness 0.035)) + (layer "B.Mask" (type "Bottom Solder Mask") (color "Green") (thickness 0.01)) + (copper_finish "ENIG") + ) + (pad_to_mask_clearance 0) + ) + (net 0 "") + (net 1 "GND") + (net 2 "+3V3") + (net 3 "VBUS") + (net 4 "SWDIO") + (net 5 "SWCLK") + (net 6 "NRST") + (net 7 "USB_D+") + (net 8 "USB_D-") + (net 9 "LED") + (net 10 "TX") + (net 11 "RX") + (gr_line (start 103 80) (end 161 80) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "00000000-0000-4000-8000-000000000001")) + (gr_arc (start 161 80) (mid 163.1213 80.8787) (end 164 83) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "00000000-0000-4000-8000-000000000002")) + (gr_line (start 164 83) (end 164 119) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "00000000-0000-4000-8000-000000000003")) + (gr_arc (start 164 119) (mid 163.1213 121.1213) (end 161 122) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "00000000-0000-4000-8000-000000000004")) + (gr_line (start 161 122) (end 103 122) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "00000000-0000-4000-8000-000000000005")) + (gr_arc (start 103 122) (mid 100.8787 121.1213) (end 100 119) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "00000000-0000-4000-8000-000000000006")) + (gr_line (start 100 119) (end 100 83) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "00000000-0000-4000-8000-000000000007")) + (gr_arc (start 100 83) (mid 100.8787 80.8787) (end 103 80) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "00000000-0000-4000-8000-000000000008")) + (gr_rect (start 150 110) (end 158 114) (stroke (width 0.1) (type default)) (fill none) (layer "Edge.Cuts") (uuid "00000000-0000-4000-8000-000000000009")) + (gr_line (start 104 119) (end 124 119) (stroke (width 0.15) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000010")) + (footprint "Package_QFP:LQFP-32_7x7mm_P0.8mm" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000011") (at 130 100) + (property "Reference" "U1" (at 0 -4.5 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "STM32G031K8" (at 0 4.5 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -3.5 -3.5) (end 3.5 3.5) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000012")) + (fp_rect (start -3.75 -3.75) (end 3.75 3.75) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000013")) + (fp_line (start -3.7 -3.7) (end 3.7 -3.7) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000014")) + (pad "1" smd roundrect (at -4.25 -2.8) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000015")) + (pad "2" smd roundrect (at -4.25 -2) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 6 "NRST") (uuid "00000000-0000-4000-8000-000000000016")) + (pad "3" smd roundrect (at -4.25 -1.2) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 9 "LED") (uuid "00000000-0000-4000-8000-000000000017")) + (pad "4" smd roundrect (at -4.25 -0.4) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 10 "TX") (uuid "00000000-0000-4000-8000-000000000018")) + (pad "5" smd roundrect (at -4.25 0.4) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 11 "RX") (uuid "00000000-0000-4000-8000-000000000019")) + (pad "6" smd roundrect (at -4.25 1.2) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 8 "USB_D-") (uuid "00000000-0000-4000-8000-000000000020")) + (pad "7" smd roundrect (at -4.25 2) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 7 "USB_D+") (uuid "00000000-0000-4000-8000-000000000021")) + (pad "8" smd roundrect (at -4.25 2.8) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 4 "SWDIO") (uuid "00000000-0000-4000-8000-000000000022")) + (pad "9" smd roundrect (at -2.8 4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 5 "SWCLK") (uuid "00000000-0000-4000-8000-000000000023")) + (pad "10" smd roundrect (at -2 4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 2 "+3V3") (uuid "00000000-0000-4000-8000-000000000024")) + (pad "11" smd roundrect (at -1.2 4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000025")) + (pad "12" smd roundrect (at -0.4 4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 6 "NRST") (uuid "00000000-0000-4000-8000-000000000026")) + (pad "13" smd roundrect (at 0.4 4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 9 "LED") (uuid "00000000-0000-4000-8000-000000000027")) + (pad "14" smd roundrect (at 1.2 4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 10 "TX") (uuid "00000000-0000-4000-8000-000000000028")) + (pad "15" smd roundrect (at 2 4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 11 "RX") (uuid "00000000-0000-4000-8000-000000000029")) + (pad "16" smd roundrect (at 2.8 4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 8 "USB_D-") (uuid "00000000-0000-4000-8000-000000000030")) + (pad "17" smd roundrect (at 4.25 2.8) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 7 "USB_D+") (uuid "00000000-0000-4000-8000-000000000031")) + (pad "18" smd roundrect (at 4.25 2) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 4 "SWDIO") (uuid "00000000-0000-4000-8000-000000000032")) + (pad "19" smd roundrect (at 4.25 1.2) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 5 "SWCLK") (uuid "00000000-0000-4000-8000-000000000033")) + (pad "20" smd roundrect (at 4.25 0.4) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 2 "+3V3") (uuid "00000000-0000-4000-8000-000000000034")) + (pad "21" smd roundrect (at 4.25 -0.4) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000035")) + (pad "22" smd roundrect (at 4.25 -1.2) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 6 "NRST") (uuid "00000000-0000-4000-8000-000000000036")) + (pad "23" smd roundrect (at 4.25 -2) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 9 "LED") (uuid "00000000-0000-4000-8000-000000000037")) + (pad "24" smd roundrect (at 4.25 -2.8) (size 1.5 0.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 10 "TX") (uuid "00000000-0000-4000-8000-000000000038")) + (pad "25" smd roundrect (at 2.8 -4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 11 "RX") (uuid "00000000-0000-4000-8000-000000000039")) + (pad "26" smd roundrect (at 2 -4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 8 "USB_D-") (uuid "00000000-0000-4000-8000-000000000040")) + (pad "27" smd roundrect (at 1.2 -4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 7 "USB_D+") (uuid "00000000-0000-4000-8000-000000000041")) + (pad "28" smd roundrect (at 0.4 -4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 4 "SWDIO") (uuid "00000000-0000-4000-8000-000000000042")) + (pad "29" smd roundrect (at -0.4 -4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 5 "SWCLK") (uuid "00000000-0000-4000-8000-000000000043")) + (pad "30" smd roundrect (at -1.2 -4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 2 "+3V3") (uuid "00000000-0000-4000-8000-000000000044")) + (pad "31" smd roundrect (at -2 -4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000045")) + (pad "32" smd roundrect (at -2.8 -4.25) (size 0.5 1.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 6 "NRST") (uuid "00000000-0000-4000-8000-000000000046")) + (pad "33" smd rect (at 0 0) (size 3.5 3.5) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000047")) + ) + (footprint "Capacitor_SMD:C_0603_1608Metric" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000048") (at 122 92 90) + (property "Reference" "C1" (at 0 -1.4 90) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "100nF" (at 0 1.4 90) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -0.8 -0.4) (end 0.8 0.4) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000049")) + (fp_rect (start -1.05 -0.65) (end 1.05 0.65) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000050")) + (fp_line (start -1 -0.6) (end 1 -0.6) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000051")) + (pad "1" smd roundrect (at -0.8 0 90) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 2 "+3V3") (uuid "00000000-0000-4000-8000-000000000052")) + (pad "2" smd roundrect (at 0.8 0 90) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000053")) + ) + (footprint "Capacitor_SMD:C_0603_1608Metric" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000054") (at 138 92 90) + (property "Reference" "C2" (at 0 -1.4 90) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "100nF" (at 0 1.4 90) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -0.8 -0.4) (end 0.8 0.4) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000055")) + (fp_rect (start -1.05 -0.65) (end 1.05 0.65) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000056")) + (fp_line (start -1 -0.6) (end 1 -0.6) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000057")) + (pad "1" smd roundrect (at -0.8 0 90) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 2 "+3V3") (uuid "00000000-0000-4000-8000-000000000058")) + (pad "2" smd roundrect (at 0.8 0 90) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000059")) + ) + (footprint "Capacitor_SMD:C_0603_1608Metric" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000060") (at 122 108) + (property "Reference" "C3" (at 0 -1.4 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "4.7uF" (at 0 1.4 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -0.8 -0.4) (end 0.8 0.4) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000061")) + (fp_rect (start -1.05 -0.65) (end 1.05 0.65) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000062")) + (fp_line (start -1 -0.6) (end 1 -0.6) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000063")) + (pad "1" smd roundrect (at -0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 2 "+3V3") (uuid "00000000-0000-4000-8000-000000000064")) + (pad "2" smd roundrect (at 0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000065")) + ) + (footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000066") (at 138 108) + (property "Reference" "R1" (at 0 -1.4 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "10k" (at 0 1.4 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -0.8 -0.4) (end 0.8 0.4) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000067")) + (fp_rect (start -1.05 -0.65) (end 1.05 0.65) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000068")) + (fp_line (start -1 -0.6) (end 1 -0.6) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000069")) + (pad "1" smd roundrect (at -0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 6 "NRST") (uuid "00000000-0000-4000-8000-000000000070")) + (pad "2" smd roundrect (at 0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 2 "+3V3") (uuid "00000000-0000-4000-8000-000000000071")) + ) + (footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000072") (at 146 100 90) + (property "Reference" "R2" (at 0 -1.4 90) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "330R" (at 0 1.4 90) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -0.8 -0.4) (end 0.8 0.4) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000073")) + (fp_rect (start -1.05 -0.65) (end 1.05 0.65) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000074")) + (fp_line (start -1 -0.6) (end 1 -0.6) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000075")) + (pad "1" smd roundrect (at -0.8 0 90) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 9 "LED") (uuid "00000000-0000-4000-8000-000000000076")) + (pad "2" smd roundrect (at 0.8 0 90) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000077")) + ) + (footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000078") (at 114 97) + (property "Reference" "R3" (at 0 -1.4 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "22R" (at 0 1.4 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -0.8 -0.4) (end 0.8 0.4) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000079")) + (fp_rect (start -1.05 -0.65) (end 1.05 0.65) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000080")) + (fp_line (start -1 -0.6) (end 1 -0.6) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000081")) + (pad "1" smd roundrect (at -0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 7 "USB_D+") (uuid "00000000-0000-4000-8000-000000000082")) + (pad "2" smd roundrect (at 0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 7 "USB_D+") (uuid "00000000-0000-4000-8000-000000000083")) + ) + (footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000084") (at 114 103) + (property "Reference" "R4" (at 0 -1.4 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "22R" (at 0 1.4 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -0.8 -0.4) (end 0.8 0.4) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000085")) + (fp_rect (start -1.05 -0.65) (end 1.05 0.65) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000086")) + (fp_line (start -1 -0.6) (end 1 -0.6) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000087")) + (pad "1" smd roundrect (at -0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 8 "USB_D-") (uuid "00000000-0000-4000-8000-000000000088")) + (pad "2" smd roundrect (at 0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25) (net 8 "USB_D-") (uuid "00000000-0000-4000-8000-000000000089")) + ) + (footprint "LED_SMD:LED_0805_2012Metric" (layer "B.Cu") (uuid "00000000-0000-4000-8000-000000000090") (at 152 100 90) + (property "Reference" "D1" (at 0 -1.625 90) (layer "B.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "GREEN" (at 0 1.625 90) (layer "B.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -1 -0.625) (end 1 0.625) (stroke (width 0.1) (type default)) (fill none) (layer "B.Fab") (uuid "00000000-0000-4000-8000-000000000091")) + (fp_rect (start -1.25 -0.875) (end 1.25 0.875) (stroke (width 0.05) (type default)) (fill none) (layer "B.CrtYd") (uuid "00000000-0000-4000-8000-000000000092")) + (fp_line (start -1.2 -0.825) (end 1.2 -0.825) (stroke (width 0.12) (type default)) (layer "B.SilkS") (uuid "00000000-0000-4000-8000-000000000093")) + (pad "1" smd roundrect (at -1 0 90) (size 1 1.2) (layers "B.Cu" "B.Paste" "B.Mask") (roundrect_rratio 0.25) (net 9 "LED") (uuid "00000000-0000-4000-8000-000000000094")) + (pad "2" smd roundrect (at 1 0 90) (size 1 1.2) (layers "B.Cu" "B.Paste" "B.Mask") (roundrect_rratio 0.25) (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000095")) + ) + (footprint "Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000096") (at 130 116) + (property "Reference" "J1" (at 0 -2.27 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "SWD" (at 0 2.27 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -5.08 -1.27) (end 5.08 1.27) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000097")) + (fp_rect (start -5.33 -1.52) (end 5.33 1.52) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000098")) + (fp_line (start -5.28 -1.47) (end 5.28 -1.47) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000099")) + (pad "1" thru_hole rect (at -3.81 0) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net 2 "+3V3") (uuid "00000000-0000-4000-8000-000000000100")) + (pad "2" thru_hole oval (at -1.27 0) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net 4 "SWDIO") (uuid "00000000-0000-4000-8000-000000000101")) + (pad "3" thru_hole oval (at 1.27 0) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net 5 "SWCLK") (uuid "00000000-0000-4000-8000-000000000102")) + (pad "4" thru_hole oval (at 3.81 0) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000103")) + ) + (footprint "Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000104") (at 105 100 90) + (property "Reference" "J2" (at 0 -2.27 90) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "USB" (at 0 2.27 90) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -5.08 -1.27) (end 5.08 1.27) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000105")) + (fp_rect (start -5.33 -1.52) (end 5.33 1.52) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000106")) + (fp_line (start -5.28 -1.47) (end 5.28 -1.47) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000107")) + (pad "1" thru_hole rect (at -3.81 0 90) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net 3 "VBUS") (uuid "00000000-0000-4000-8000-000000000108")) + (pad "2" thru_hole oval (at -1.27 0 90) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net 8 "USB_D-") (uuid "00000000-0000-4000-8000-000000000109")) + (pad "3" thru_hole oval (at 1.27 0 90) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net 7 "USB_D+") (uuid "00000000-0000-4000-8000-000000000110")) + (pad "4" thru_hole oval (at 3.81 0 90) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net 1 "GND") (uuid "00000000-0000-4000-8000-000000000111")) + ) + (footprint "MountingHole:MountingHole_3.2mm_M3" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000112") (at 104 84) + (property "Reference" "H1" (at 0 -4 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "M3" (at 0 4 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -3 -3) (end 3 3) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000113")) + (fp_rect (start -3.25 -3.25) (end 3.25 3.25) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000114")) + (fp_line (start -3.2 -3.2) (end 3.2 -3.2) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000115")) + (pad "" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers "*.Cu" "*.Mask") (uuid "00000000-0000-4000-8000-000000000116")) + ) + (footprint "MountingHole:MountingHole_3.2mm_M3" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000117") (at 160 84) + (property "Reference" "H2" (at 0 -4 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "M3" (at 0 4 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -3 -3) (end 3 3) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000118")) + (fp_rect (start -3.25 -3.25) (end 3.25 3.25) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000119")) + (fp_line (start -3.2 -3.2) (end 3.2 -3.2) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000120")) + (pad "" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers "*.Cu" "*.Mask") (uuid "00000000-0000-4000-8000-000000000121")) + ) + (footprint "MountingHole:MountingHole_3.2mm_M3" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000122") (at 104 118) + (property "Reference" "H3" (at 0 -4 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "M3" (at 0 4 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -3 -3) (end 3 3) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000123")) + (fp_rect (start -3.25 -3.25) (end 3.25 3.25) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000124")) + (fp_line (start -3.2 -3.2) (end 3.2 -3.2) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000125")) + (pad "" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers "*.Cu" "*.Mask") (uuid "00000000-0000-4000-8000-000000000126")) + ) + (footprint "MountingHole:MountingHole_3.2mm_M3" (layer "F.Cu") (uuid "00000000-0000-4000-8000-000000000127") (at 160 118) + (property "Reference" "H4" (at 0 -4 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "M3" (at 0 4 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_rect (start -3 -3) (end 3 3) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab") (uuid "00000000-0000-4000-8000-000000000128")) + (fp_rect (start -3.25 -3.25) (end 3.25 3.25) (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd") (uuid "00000000-0000-4000-8000-000000000129")) + (fp_line (start -3.2 -3.2) (end 3.2 -3.2) (stroke (width 0.12) (type default)) (layer "F.SilkS") (uuid "00000000-0000-4000-8000-000000000130")) + (pad "" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers "*.Cu" "*.Mask") (uuid "00000000-0000-4000-8000-000000000131")) + ) + (segment (start 105 97.46) (end 109 97) (width 0.3) (layer "F.Cu") (net 7) (uuid "00000000-0000-4000-8000-000000000132")) + (segment (start 109 97) (end 113.2 97) (width 0.3) (layer "F.Cu") (net 7) (uuid "00000000-0000-4000-8000-000000000133")) + (segment (start 105 100) (end 109 103) (width 0.3) (layer "F.Cu") (net 8) (uuid "00000000-0000-4000-8000-000000000134")) + (segment (start 109 103) (end 113.2 103) (width 0.3) (layer "F.Cu") (net 8) (uuid "00000000-0000-4000-8000-000000000135")) + (segment (start 114.8 97) (end 120 97) (width 0.3) (layer "F.Cu") (net 7) (uuid "00000000-0000-4000-8000-000000000136")) + (arc (start 120 97) (mid 121.8 97.8) (end 122.6 99.6) (width 0.3) (layer "F.Cu") (net 7) (uuid "00000000-0000-4000-8000-000000000137")) + (segment (start 122.6 99.6) (end 125.75 99.6) (width 0.3) (layer "F.Cu") (net 7) (uuid "00000000-0000-4000-8000-000000000138")) + (segment (start 114.8 103) (end 120 103) (width 0.3) (layer "F.Cu") (net 8) (uuid "00000000-0000-4000-8000-000000000139")) + (arc (start 120 103) (mid 121.8 102.2) (end 122.6 100.4) (width 0.3) (layer "F.Cu") (net 8) (uuid "00000000-0000-4000-8000-000000000140")) + (segment (start 122.6 100.4) (end 125.75 100.4) (width 0.3) (layer "F.Cu") (net 8) (uuid "00000000-0000-4000-8000-000000000141")) + (segment (start 122 91.2) (end 122 88) (width 0.5) (layer "F.Cu") (net 2) (uuid "00000000-0000-4000-8000-000000000142")) + (segment (start 122 88) (end 138 88) (width 0.5) (layer "F.Cu") (net 2) (uuid "00000000-0000-4000-8000-000000000143")) + (segment (start 138 88) (end 138 91.2) (width 0.5) (layer "F.Cu") (net 2) (uuid "00000000-0000-4000-8000-000000000144")) + (segment (start 130 88) (end 130 95.75) (width 0.4) (layer "F.Cu") (net 2) (uuid "00000000-0000-4000-8000-000000000145")) + (via (at 122 94) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000146")) + (via (at 138 94) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000147")) + (segment (start 122 92.8) (end 122 94) (width 0.4) (layer "F.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000148")) + (segment (start 138 92.8) (end 138 94) (width 0.4) (layer "F.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000149")) + (segment (start 134.25 100) (end 145.2 100) (width 0.25) (layer "F.Cu") (net 9) (uuid "00000000-0000-4000-8000-000000000150")) + (segment (start 146 99.2) (end 149 99.2) (width 0.25) (layer "F.Cu") (net 9) (uuid "00000000-0000-4000-8000-000000000151")) + (via (at 149 99.2) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 9) (uuid "00000000-0000-4000-8000-000000000152")) + (segment (start 149 99.2) (end 152 99) (width 0.25) (layer "B.Cu") (net 9) (uuid "00000000-0000-4000-8000-000000000153")) + (via (at 130 106.5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 4) (uuid "00000000-0000-4000-8000-000000000154")) + (segment (start 130 104.25) (end 130 106.5) (width 0.25) (layer "F.Cu") (net 4) (uuid "00000000-0000-4000-8000-000000000155")) + (segment (start 130 106.5) (end 128.73 113) (width 0.25) (layer "B.Cu") (net 4) (uuid "00000000-0000-4000-8000-000000000156")) + (segment (start 128.73 113) (end 128.73 116) (width 0.25) (layer "B.Cu") (net 4) (uuid "00000000-0000-4000-8000-000000000157")) + (via (at 132 106.5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 5) (uuid "00000000-0000-4000-8000-000000000158")) + (segment (start 132 104.25) (end 132 106.5) (width 0.25) (layer "F.Cu") (net 5) (uuid "00000000-0000-4000-8000-000000000159")) + (segment (start 132 106.5) (end 131.27 113) (width 0.25) (layer "B.Cu") (net 5) (uuid "00000000-0000-4000-8000-000000000160")) + (segment (start 131.27 113) (end 131.27 116) (width 0.25) (layer "B.Cu") (net 5) (uuid "00000000-0000-4000-8000-000000000161")) + (via (at 108 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000162")) + (via (at 112.2 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000163")) + (via (at 116.4 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000164")) + (via (at 120.6 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000165")) + (via (at 124.8 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000166")) + (via (at 129 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000167")) + (via (at 133.2 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000168")) + (via (at 137.4 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000169")) + (via (at 141.6 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000170")) + (via (at 145.8 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000171")) + (via (at 150 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000172")) + (via (at 154.2 119) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000173")) + (via (at 160 88) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000174")) + (via (at 160 92.5) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000175")) + (via (at 160 97) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000176")) + (via (at 160 101.5) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000177")) + (via (at 160 106) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000178")) + (via (at 160 110.5) (size 0.5) (drill 0.25) (layers "F.Cu" "B.Cu") (net 1) (uuid "00000000-0000-4000-8000-000000000179")) + (segment (start 108 86) (end 147 86) (width 1) (layer "In1.Cu") (net 2) (uuid "00000000-0000-4000-8000-000000000180")) + (segment (start 147 86) (end 147 116) (width 1) (layer "In1.Cu") (net 2) (uuid "00000000-0000-4000-8000-000000000181")) + (segment (start 110 110) (end 146 110) (width 0.2) (layer "In2.Cu") (net 10) (uuid "00000000-0000-4000-8000-000000000182")) + (segment (start 110 111) (end 146 111) (width 0.2) (layer "In2.Cu") (net 11) (uuid "00000000-0000-4000-8000-000000000183")) + (zone (net 1) (net_name "GND") (layer "B.Cu") (uuid "00000000-0000-4000-8000-000000000184") (hatch edge 0.5) + (connect_pads (clearance 0.3)) (min_thickness 0.25) (fill yes (thermal_gap 0.5) (thermal_bridge_width 0.5)) + (polygon (pts (xy 101 81) (xy 163 81) (xy 163 121) (xy 101 121))) + (filled_polygon (layer "B.Cu") (pts (xy 101.5 81.5) (xy 162.5 81.5) (xy 162.5 108.5) (xy 149.5 108.5) (xy 149.5 115.5) (xy 162.5 115.5) (xy 162.5 120.5) (xy 101.5 120.5))) + ) + (zone (net 1) (net_name "GND") (layer "In2.Cu") (uuid "00000000-0000-4000-8000-000000000185") (hatch edge 0.5) + (polygon (pts (xy 130 82) (xy 162 82) (xy 162 106) (xy 130 106))) + (filled_polygon (layer "In2.Cu") (pts (xy 130 82) (xy 162 82) (xy 162 106) (xy 130 106))) + ) +) diff --git a/pcb-portal/scripts/create-admin.js b/pcb-portal/scripts/create-admin.js new file mode 100644 index 0000000..e8522a1 --- /dev/null +++ b/pcb-portal/scripts/create-admin.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +// Create (or promote) an admin account: +// npm run create-admin -- admin@example.com 'a-strong-password' "Your Name" +import { loadDotEnv, readConfig } from '../server/config.js'; +import { openDb } from '../server/db.js'; +import { hashPassword } from '../server/auth.js'; + +loadDotEnv(); +const [email, password, name = 'QodeX Admin'] = process.argv.slice(2); +if (!email || !password || password.length < 8) { + console.error('usage: npm run create-admin -- [name]'); + process.exit(1); +} +const config = readConfig(); +const db = openDb(config.dataDir); +const hash = await hashPassword(password); +const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase()); +if (existing) { + db.prepare("UPDATE users SET role = 'admin', password_hash = ?, name = ? WHERE id = ?").run(hash, name, existing.id); + console.log(`updated ${email} → admin (password reset)`); +} else { + db.prepare("INSERT INTO users (email, name, password_hash, role) VALUES (?, ?, ?, 'admin')").run(email.toLowerCase(), name, hash); + console.log(`created admin ${email}`); +} +db.close(); diff --git a/pcb-portal/scripts/make-demo-board.js b/pcb-portal/scripts/make-demo-board.js new file mode 100644 index 0000000..dce2af9 --- /dev/null +++ b/pcb-portal/scripts/make-demo-board.js @@ -0,0 +1,228 @@ +#!/usr/bin/env node +// Generates public/samples/demo-board.kicad_pcb — a small 4-layer KiCad 8 board +// (MCU + passives + header + mounting holes + GND pour) used by the landing-page +// 3D demo and by the test-suite. Deterministic output; re-run after edits. +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const W = 64, H = 42, OX = 100, OY = 80, R = 3; // board size / origin / corner radius +const f = (v) => +v.toFixed(4); +const out = []; +const uuid = (() => { + let n = 0; + return () => `00000000-0000-4000-8000-${String(++n).padStart(12, '0')}`; +})(); + +out.push(`(kicad_pcb + (version 20240108) + (generator "qodex_demo") + (generator_version "8.0") + (general (thickness 1.6) (legacy_teardrops no)) + (paper "A4") + (layers + (0 "F.Cu" signal) + (1 "In1.Cu" power) + (2 "In2.Cu" signal) + (31 "B.Cu" signal) + (36 "B.SilkS" user "B.Silkscreen") + (37 "F.SilkS" user "F.Silkscreen") + (38 "B.Mask" user) + (39 "F.Mask" user) + (44 "Edge.Cuts" user) + (46 "B.CrtYd" user "B.Courtyard") + (47 "F.CrtYd" user "F.Courtyard") + (48 "B.Fab" user) + (49 "F.Fab" user) + ) + (setup + (stackup + (layer "F.SilkS" (type "Top Silk Screen")) + (layer "F.Mask" (type "Top Solder Mask") (color "Green") (thickness 0.01)) + (layer "F.Cu" (type "copper") (thickness 0.035)) + (layer "dielectric 1" (type "prepreg") (thickness 0.2104) (material "FR4") (epsilon_r 4.4)) + (layer "In1.Cu" (type "copper") (thickness 0.0152)) + (layer "dielectric 2" (type "core") (thickness 1.065) (material "FR4") (epsilon_r 4.6)) + (layer "In2.Cu" (type "copper") (thickness 0.0152)) + (layer "dielectric 3" (type "prepreg") (thickness 0.2104) (material "FR4") (epsilon_r 4.4)) + (layer "B.Cu" (type "copper") (thickness 0.035)) + (layer "B.Mask" (type "Bottom Solder Mask") (color "Green") (thickness 0.01)) + (copper_finish "ENIG") + ) + (pad_to_mask_clearance 0) + )`); + +const nets = ['', 'GND', '+3V3', 'VBUS', 'SWDIO', 'SWCLK', 'NRST', 'USB_D+', 'USB_D-', 'LED', 'TX', 'RX']; +nets.forEach((n, i) => out.push(` (net ${i} "${n}")`)); +const netId = (name) => nets.indexOf(name); + +// ── outline: rounded rectangle from 4 lines + 4 arcs ── +const x0 = OX, y0 = OY, x1 = OX + W, y1 = OY + H; +const line = (a, b, layer = 'Edge.Cuts', w = 0.1) => + out.push(` (gr_line (start ${f(a[0])} ${f(a[1])}) (end ${f(b[0])} ${f(b[1])}) (stroke (width ${w}) (type default)) (layer "${layer}") (uuid "${uuid()}"))`); +const arc = (s, m, e) => + out.push(` (gr_arc (start ${f(s[0])} ${f(s[1])}) (mid ${f(m[0])} ${f(m[1])}) (end ${f(e[0])} ${f(e[1])}) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "${uuid()}"))`); +const k = R * (1 - Math.SQRT1_2); +line([x0 + R, y0], [x1 - R, y0]); +arc([x1 - R, y0], [x1 - k, y0 + k], [x1, y0 + R]); +line([x1, y0 + R], [x1, y1 - R]); +arc([x1, y1 - R], [x1 - k, y1 - k], [x1 - R, y1]); +line([x1 - R, y1], [x0 + R, y1]); +arc([x0 + R, y1], [x0 + k, y1 - k], [x0, y1 - R]); +line([x0, y1 - R], [x0, y0 + R]); +arc([x0, y0 + R], [x0 + k, y0 + k], [x0 + R, y0]); +// internal slot cut-out +out.push(` (gr_rect (start ${x0 + 50} ${y0 + 30}) (end ${x0 + 58} ${y0 + 34}) (stroke (width 0.1) (type default)) (fill none) (layer "Edge.Cuts") (uuid "${uuid()}"))`); +// board title on silkscreen +line([x0 + 4, y1 - 3], [x0 + 24, y1 - 3], 'F.SilkS', 0.15); + +// ── footprints ── +function footprint({ lib, ref, value, x, y, rot = 0, side = 'F', pads, body }) { + const L = (l) => (side === 'B' ? l.replace(/^F\./, 'B.') : l); + const lines = []; + lines.push(` (footprint "${lib}" (layer "${L('F.Cu')}") (uuid "${uuid()}") (at ${f(x)} ${f(y)}${rot ? ' ' + rot : ''})`); + lines.push(` (property "Reference" "${ref}" (at 0 ${f(-body[1] / 2 - 1)} ${rot || 0}) (layer "${L('F.SilkS')}") (effects (font (size 1 1) (thickness 0.15))))`); + lines.push(` (property "Value" "${value}" (at 0 ${f(body[1] / 2 + 1)} ${rot || 0}) (layer "${L('F.Fab')}") (effects (font (size 1 1) (thickness 0.15))))`); + const [bw, bh] = body; + lines.push(` (fp_rect (start ${f(-bw / 2)} ${f(-bh / 2)}) (end ${f(bw / 2)} ${f(bh / 2)}) (stroke (width 0.1) (type default)) (fill none) (layer "${L('F.Fab')}") (uuid "${uuid()}"))`); + lines.push(` (fp_rect (start ${f(-bw / 2 - 0.25)} ${f(-bh / 2 - 0.25)}) (end ${f(bw / 2 + 0.25)} ${f(bh / 2 + 0.25)}) (stroke (width 0.05) (type default)) (fill none) (layer "${L('F.CrtYd')}") (uuid "${uuid()}"))`); + lines.push(` (fp_line (start ${f(-bw / 2 - 0.2)} ${f(-bh / 2 - 0.2)}) (end ${f(bw / 2 + 0.2)} ${f(-bh / 2 - 0.2)}) (stroke (width 0.12) (type default)) (layer "${L('F.SilkS')}") (uuid "${uuid()}"))`); + for (const p of pads) { + const ang = (rot || 0) + (p.rot || 0); + const net = p.net ? ` (net ${netId(p.net)} "${p.net}")` : ''; + if (p.tht) { + lines.push(` (pad "${p.n}" thru_hole ${p.shape || 'circle'} (at ${f(p.x)} ${f(p.y)}${ang ? ' ' + ang : ''}) (size ${p.w} ${p.h}) (drill ${p.drill}) (layers "*.Cu" "*.Mask")${net} (uuid "${uuid()}"))`); + } else if (p.npth) { + lines.push(` (pad "" np_thru_hole circle (at ${f(p.x)} ${f(p.y)}) (size ${p.w} ${p.w}) (drill ${p.w}) (layers "*.Cu" "*.Mask") (uuid "${uuid()}"))`); + } else { + lines.push(` (pad "${p.n}" smd ${p.shape || 'roundrect'} (at ${f(p.x)} ${f(p.y)}${ang ? ' ' + ang : ''}) (size ${p.w} ${p.h}) (layers "${L('F.Cu')}" "${L('F.Paste')}" "${L('F.Mask')}") (roundrect_rratio 0.25)${net} (uuid "${uuid()}"))`); + } + } + lines.push(' )'); + out.push(lines.join('\n')); +} + +// MCU — LQFP-32, 7x7 body, 0.8 mm pitch +const qfpPads = []; +const qfpNets = ['+3V3', 'GND', 'NRST', 'LED', 'TX', 'RX', 'USB_D-', 'USB_D+', 'SWDIO', 'SWCLK']; +for (let side = 0; side < 4; side++) { + for (let i = 0; i < 8; i++) { + const t = -2.8 + i * 0.8; + const n = side * 8 + i + 1; + const net = qfpNets[n % qfpNets.length]; + if (side === 0) qfpPads.push({ n, x: -4.25, y: t, w: 1.5, h: 0.5, net }); + if (side === 1) qfpPads.push({ n, x: t, y: 4.25, w: 0.5, h: 1.5, net }); + if (side === 2) qfpPads.push({ n, x: 4.25, y: -t, w: 1.5, h: 0.5, net }); + if (side === 3) qfpPads.push({ n, x: -t, y: -4.25, w: 0.5, h: 1.5, net }); + } +} +qfpPads.push({ n: 33, x: 0, y: 0, w: 3.5, h: 3.5, net: 'GND', shape: 'rect' }); +const U1 = { x: OX + 30, y: OY + 20 }; +footprint({ lib: 'Package_QFP:LQFP-32_7x7mm_P0.8mm', ref: 'U1', value: 'STM32G031K8', ...U1, pads: qfpPads, body: [7, 7] }); + +// passives 0603 around the MCU +const passives = [ + { ref: 'C1', value: '100nF', x: OX + 22, y: OY + 12, rot: 90, nets: ['+3V3', 'GND'] }, + { ref: 'C2', value: '100nF', x: OX + 38, y: OY + 12, rot: 90, nets: ['+3V3', 'GND'] }, + { ref: 'C3', value: '4.7uF', x: OX + 22, y: OY + 28, rot: 0, nets: ['+3V3', 'GND'] }, + { ref: 'R1', value: '10k', x: OX + 38, y: OY + 28, rot: 0, nets: ['NRST', '+3V3'] }, + { ref: 'R2', value: '330R', x: OX + 46, y: OY + 20, rot: 90, nets: ['LED', 'GND'] }, + { ref: 'R3', value: '22R', x: OX + 14, y: OY + 17, rot: 0, nets: ['USB_D+', 'USB_D+'] }, + { ref: 'R4', value: '22R', x: OX + 14, y: OY + 23, rot: 0, nets: ['USB_D-', 'USB_D-'] }, +]; +for (const p of passives) { + footprint({ + lib: p.ref.startsWith('C') ? 'Capacitor_SMD:C_0603_1608Metric' : 'Resistor_SMD:R_0603_1608Metric', + ref: p.ref, value: p.value, x: p.x, y: p.y, rot: p.rot, + pads: [{ n: 1, x: -0.8, y: 0, w: 0.9, h: 0.95, net: p.nets[0] }, { n: 2, x: 0.8, y: 0, w: 0.9, h: 0.95, net: p.nets[1] }], + body: [1.6, 0.8], + }); +} +// LED on the bottom side +footprint({ + lib: 'LED_SMD:LED_0805_2012Metric', ref: 'D1', value: 'GREEN', x: OX + 52, y: OY + 20, rot: 90, side: 'B', + pads: [{ n: 1, x: -1, y: 0, w: 1, h: 1.2, net: 'LED' }, { n: 2, x: 1, y: 0, w: 1, h: 1.2, net: 'GND' }], + body: [2, 1.25], +}); +// 1x4 pin header (SWD) and USB-ish 1x4 header +for (const [ref, x, y, netsH] of [['J1', OX + 30, OY + 36, ['+3V3', 'SWDIO', 'SWCLK', 'GND']], ['J2', OX + 5, OY + 20, ['VBUS', 'USB_D-', 'USB_D+', 'GND']]]) { + const vertical = ref === 'J2'; + footprint({ + lib: 'Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical', ref, value: ref === 'J1' ? 'SWD' : 'USB', x, y, rot: vertical ? 90 : 0, + pads: netsH.map((net, i) => ({ n: i + 1, x: -3.81 + i * 2.54, y: 0, w: 1.7, h: 1.7, drill: 1, tht: true, shape: i === 0 ? 'rect' : 'oval', net })), + body: [10.16, 2.54], + }); +} +// mounting holes +for (const [i, mx, my] of [[1, OX + 4, OY + 4], [2, OX + W - 4, OY + 4], [3, OX + 4, OY + H - 4], [4, OX + W - 4, OY + H - 4]]) { + footprint({ lib: 'MountingHole:MountingHole_3.2mm_M3', ref: `H${i}`, value: 'M3', x: mx, y: my, pads: [{ npth: true, x: 0, y: 0, w: 3.2 }], body: [6, 6] }); +} + +// ── tracks ── +const seg = (a, b, w, layer, net) => + out.push(` (segment (start ${f(a[0])} ${f(a[1])}) (end ${f(b[0])} ${f(b[1])}) (width ${w}) (layer "${layer}") (net ${netId(net)}) (uuid "${uuid()}"))`); +const via = (p, net, size = 0.6, drill = 0.3) => + out.push(` (via (at ${f(p[0])} ${f(p[1])}) (size ${size}) (drill ${drill}) (layers "F.Cu" "B.Cu") (net ${netId(net)}) (uuid "${uuid()}"))`); +const tArc = (s, m, e, w, layer, net) => + out.push(` (arc (start ${f(s[0])} ${f(s[1])}) (mid ${f(m[0])} ${f(m[1])}) (end ${f(e[0])} ${f(e[1])}) (width ${w}) (layer "${layer}") (net ${netId(net)}) (uuid "${uuid()}"))`); + +// USB diff pair from J2 → R3/R4 → MCU (F.Cu) +seg([OX + 5, OY + 17.46], [OX + 9, OY + 17], 0.3, 'F.Cu', 'USB_D+'); +seg([OX + 9, OY + 17], [OX + 13.2, OY + 17], 0.3, 'F.Cu', 'USB_D+'); +seg([OX + 5, OY + 20 + 1.27 - 1.27 + 2.54 - 2.54], [OX + 9, OY + 23], 0.3, 'F.Cu', 'USB_D-'); +seg([OX + 9, OY + 23], [OX + 13.2, OY + 23], 0.3, 'F.Cu', 'USB_D-'); +seg([OX + 14.8, OY + 17], [OX + 20, OY + 17], 0.3, 'F.Cu', 'USB_D+'); +tArc([OX + 20, OY + 17], [OX + 21.8, OY + 17.8], [OX + 22.6, OY + 19.6], 0.3, 'F.Cu', 'USB_D+'); +seg([OX + 22.6, OY + 19.6], [OX + 25.75, OY + 19.6], 0.3, 'F.Cu', 'USB_D+'); +seg([OX + 14.8, OY + 23], [OX + 20, OY + 23], 0.3, 'F.Cu', 'USB_D-'); +tArc([OX + 20, OY + 23], [OX + 21.8, OY + 22.2], [OX + 22.6, OY + 20.4], 0.3, 'F.Cu', 'USB_D-'); +seg([OX + 22.6, OY + 20.4], [OX + 25.75, OY + 20.4], 0.3, 'F.Cu', 'USB_D-'); +// power rails +seg([OX + 22, OY + 11.2], [OX + 22, OY + 8], 0.5, 'F.Cu', '+3V3'); +seg([OX + 22, OY + 8], [OX + 38, OY + 8], 0.5, 'F.Cu', '+3V3'); +seg([OX + 38, OY + 8], [OX + 38, OY + 11.2], 0.5, 'F.Cu', '+3V3'); +seg([OX + 30, OY + 8], [OX + 30, OY + 15.75], 0.4, 'F.Cu', '+3V3'); +via([OX + 22, OY + 14], 'GND'); +via([OX + 38, OY + 14], 'GND'); +seg([OX + 22, OY + 12.8], [OX + 22, OY + 14], 0.4, 'F.Cu', 'GND'); +seg([OX + 38, OY + 12.8], [OX + 38, OY + 14], 0.4, 'F.Cu', 'GND'); +// LED net to the bottom side through a via +seg([OX + 34.25, OY + 20], [OX + 45.2, OY + 20], 0.25, 'F.Cu', 'LED'); +seg([OX + 46, OY + 19.2], [OX + 49, OY + 19.2], 0.25, 'F.Cu', 'LED'); +via([OX + 49, OY + 19.2], 'LED'); +seg([OX + 49, OY + 19.2], [OX + 52, OY + 19], 0.25, 'B.Cu', 'LED'); +// SWD lines down to J1 on B.Cu +for (const [i, net] of [[1, 'SWDIO'], [2, 'SWCLK']]) { + const vx = OX + 28 + i * 2; + via([vx, OY + 26.5], net); + seg([vx, OY + 24.25], [vx, OY + 26.5], 0.25, 'F.Cu', net); + seg([vx, OY + 26.5], [OX + 30 - 3.81 + i * 2.54, OY + 33], 0.25, 'B.Cu', net); + seg([OX + 30 - 3.81 + i * 2.54, OY + 33], [OX + 30 - 3.81 + i * 2.54, OY + 36], 0.25, 'B.Cu', net); +} +// stitching vias +for (let i = 0; i < 12; i++) via([OX + 8 + i * 4.2, OY + 39], 'GND', 0.5, 0.25); +for (let i = 0; i < 6; i++) via([OX + 60, OY + 8 + i * 4.5], 'GND', 0.5, 0.25); +// inner-layer power distribution +seg([OX + 8, OY + 6], [OX + 47, OY + 6], 1.0, 'In1.Cu', '+3V3'); +seg([OX + 47, OY + 6], [OX + 47, OY + 36], 1.0, 'In1.Cu', '+3V3'); +seg([OX + 10, OY + 30], [OX + 46, OY + 30], 0.2, 'In2.Cu', 'TX'); +seg([OX + 10, OY + 31], [OX + 46, OY + 31], 0.2, 'In2.Cu', 'RX'); + +// ── GND pour on B.Cu (with a filled polygon, as KiCad saves it) ── +const m = 1; +const pour = [[x0 + m, y0 + m], [x1 - m, y0 + m], [x1 - m, y1 - m], [x0 + m, y1 - m]]; +const pts = (list) => list.map((p) => `(xy ${f(p[0])} ${f(p[1])})`).join(' '); +out.push(` (zone (net ${netId('GND')}) (net_name "GND") (layer "B.Cu") (uuid "${uuid()}") (hatch edge 0.5) + (connect_pads (clearance 0.3)) (min_thickness 0.25) (fill yes (thermal_gap 0.5) (thermal_bridge_width 0.5)) + (polygon (pts ${pts(pour)})) + (filled_polygon (layer "B.Cu") (pts ${pts([[x0 + 1.5, y0 + 1.5], [x1 - 1.5, y0 + 1.5], [x1 - 1.5, y0 + 28.5], [x0 + 49.5, y0 + 28.5], [x0 + 49.5, y0 + 35.5], [x1 - 1.5, y0 + 35.5], [x1 - 1.5, y1 - 1.5], [x0 + 1.5, y1 - 1.5]])})) + )`); +out.push(` (zone (net ${netId('GND')}) (net_name "GND") (layer "In2.Cu") (uuid "${uuid()}") (hatch edge 0.5) + (polygon (pts ${pts([[x0 + 30, y0 + 2], [x1 - 2, y0 + 2], [x1 - 2, y0 + 26], [x0 + 30, y0 + 26]])})) + (filled_polygon (layer "In2.Cu") (pts ${pts([[x0 + 30, y0 + 2], [x1 - 2, y0 + 2], [x1 - 2, y0 + 26], [x0 + 30, y0 + 26]])})) + )`); +out.push(')\n'); + +const dest = join(dirname(fileURLToPath(import.meta.url)), '..', 'public', 'samples', 'demo-board.kicad_pcb'); +writeFileSync(dest, out.join('\n')); +console.log('wrote', dest); diff --git a/pcb-portal/server/app.js b/pcb-portal/server/app.js new file mode 100644 index 0000000..9c86854 --- /dev/null +++ b/pcb-portal/server/app.js @@ -0,0 +1,253 @@ +import express from 'express'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { ROOT } from './config.js'; +import { openDb, getSetting, setSetting } from './db.js'; +import { csrfGuard, hashPassword, sessionMiddleware } from './auth.js'; +import { createStorage } from './storage.js'; +import { createPaymentProviders } from './payments/index.js'; +import { createPaymentService } from './payments/service.js'; +import { defaultPricing } from './pricing.js'; +import { apiRouter } from './routes/api.js'; +import { adminRouter } from './routes/admin.js'; +import { esc, renderView } from './layout.js'; +import { USE_CASES, USE_CASE_BY_SLUG } from './content.js'; + +const PUBLIC = join(ROOT, 'public'); + +// [route, view, auth, sitemap priority] +const PAGES = [ + ['/', 'site/home.html', null, 1.0], + ['/how-it-works', 'site/how-it-works.html', null, 0.9], + ['/studio', 'site/studio.html', null, 0.9], + ['/use-cases', 'site/use-cases.html', null, 0.8], + ['/integrations', 'site/integrations.html', null, 0.8], + ['/pricing', 'site/pricing.html', null, 0.9], + ['/evidence-library', 'site/evidence.html', null, 0.7], + ['/docs', 'site/docs.html', null, 0.8], + ['/trust-security', 'site/trust.html', null, 0.7], + ['/about', 'site/about.html', null, 0.5], + ['/careers', 'site/careers.html', null, 0.4], + ['/contact', 'site/contact.html', null, 0.6], + ['/legal', 'site/legal.html', null, 0.3], + ['/login', 'site/login.html', null, null], + ['/register', 'site/register.html', null, null], + ['/portal', 'portal/dashboard.html', 'user'], + ['/portal/new', 'portal/new-order.html', 'user'], + ['/portal/orders/:code', 'portal/order.html', 'user'], + ['/portal/files/:id/view', 'portal/file-viewer.html', 'user'], + ['/portal/billing', 'portal/billing.html', 'user'], + ['/portal/receipts/:pid', 'portal/receipt.html', 'user'], + ['/portal/settings', 'portal/settings.html', 'user'], + ['/pay/mock/:pid', 'portal/pay-mock.html', 'user'], + ['/admin', 'admin/orders.html', 'admin'], + ['/admin/orders/:code', 'admin/order.html', 'admin'], + ['/admin/customers', 'admin/customers.html', 'admin'], + ['/admin/inquiries', 'admin/inquiries.html', 'admin'], + ['/admin/settings', 'admin/settings.html', 'admin'], +]; + +const IMPORT_MAP = JSON.stringify({ + imports: { + three: '/vendor/three/build/three.module.js', + 'three/addons/': '/vendor/three/examples/jsm/', + }, +}); +const IMPORT_MAP_TAG = ``; +const IMPORT_MAP_HASH = createHash('sha256').update(IMPORT_MAP).digest('base64'); + +const CSP = [ + "default-src 'self'", + `script-src 'self' 'sha256-${IMPORT_MAP_HASH}'`, + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com data:", + "img-src 'self' data: blob:", + "connect-src 'self' blob: data:", + "worker-src 'self' blob:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +].join('; '); + +/** + * Build the express app. `deps.fetch` lets tests stub the payment gateways. + * Returns { app, db, close }. + */ +export async function createApp(config, deps = {}) { + const db = openDb(config.dataDir); + const storage = createStorage(config.dataDir); + const providers = createPaymentProviders(config.payment, deps.fetch || globalThis.fetch); + const ctx = { db, storage, config, providers }; + ctx.payments = createPaymentService(ctx); + + if (!getSetting(db, 'pricing')) setSetting(db, 'pricing', defaultPricing()); + await ensureAdmin(db, config.admin); + + const app = express(); + app.disable('x-powered-by'); + if (config.trustProxy) app.set('trust proxy', 1); + + app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + res.setHeader('Content-Security-Policy', CSP); + if (config.secureCookies) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + next(); + }); + + // Stripe webhook needs the raw body for signature verification, so it is + // mounted before the JSON parser and outside the CSRF guard. + app.post('/api/payments/stripe/webhook', express.raw({ type: '*/*', limit: '1mb' }), async (req, res) => { + const stripe = providers.get('stripe'); + const event = stripe?.parseWebhook(req.body, req.get('stripe-signature')); + if (!event) return res.status(400).json({ error: 'Invalid signature.' }); + if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') { + const session = event.data?.object || {}; + const payment = ctx.payments.byPublicId(session.client_reference_id); + if (payment && payment.provider === 'stripe' && payment.authority === session.id) { + await ctx.payments.finalize(payment).catch((err) => console.error('[stripe webhook]', err)); + } + } + res.json({ received: true }); + }); + + app.use(sessionMiddleware(db)); + + app.use('/api', express.json({ limit: '1mb' }), csrfGuard, apiRouter(ctx)); + app.use('/api/admin', adminRouter(ctx)); + app.use('/api', (_req, res) => res.status(404).json({ error: 'Not found.' })); + + // static assets + app.use('/vendor/three', express.static(join(ROOT, 'node_modules', 'three'), { maxAge: '7d', index: false })); + app.use(express.static(PUBLIC, { index: false, redirect: false, maxAge: '1h' })); + + const render = (req, res, view, vars = {}, status = 200) => { + res.status(status).type('html').setHeader('Cache-Control', 'no-store'); + res.send(renderView(view, { user: req.user, config, path: req.path, vars, importMapTag: IMPORT_MAP_TAG })); + }; + + for (const [route, view, auth] of PAGES) { + app.get(route, (req, res) => { + if (auth && !req.user) return res.redirect(302, `/login?next=${encodeURIComponent(req.originalUrl)}`); + if (auth === 'admin' && req.user.role !== 'admin') return res.redirect(302, '/portal'); + render(req, res, view, pageVars(route)); + }); + } + + app.get('/use-cases/:slug', (req, res, next) => { + const uc = USE_CASE_BY_SLUG.get(req.params.slug); + if (!uc) return next(); + render(req, res, 'site/use-case.html', useCaseVars(uc)); + }); + + app.get('/viewer', (_req, res) => res.redirect(301, '/studio')); + + // ── machine-readable SEO surfaces ── + const publicPaths = () => [ + ...PAGES.filter((p) => p[3] != null).map((p) => [p[0], p[3]]), + ...USE_CASES.map((u) => [`/use-cases/${u.slug}`, 0.8]), + ]; + app.get('/robots.txt', (_req, res) => { + res.type('text').send(`User-agent: *\nAllow: /\nDisallow: /portal\nDisallow: /admin\nDisallow: /api\nDisallow: /pay\n\nSitemap: ${config.baseUrl}/sitemap.xml\n`); + }); + app.get('/sitemap.xml', (_req, res) => { + const urls = publicPaths() + .map(([p, pr]) => ` ${esc(config.baseUrl + p)}${pr.toFixed(1)}`) + .join('\n'); + res.type('application/xml').send(`\n\n${urls}\n\n`); + }); + app.get('/llms.txt', (_req, res) => { + res.type('text').send(`# ${config.siteName} +> ${config.siteName} is an autonomous topological routing and DFM polishing service for KiCad printed circuit boards. Customers upload a placed .kicad_pcb, pay online in USD, and receive a routed board, a 3D model, fabrication outputs and a DFM/DRC evidence dossier. + +## Capabilities +- Lossless S-expression round-trip for KiCad 6, 7, 8 and 9 boards +- Constraint-driven routing: differential pairs, length-matched groups, controlled impedance (IPC-2141A) +- BGA escape routing with through, blind, buried and microvias (IPC-2226) +- DFM polish: acid-trap removal, teardrops, sliver and annular-ring checks against the chosen fab's capability table +- In-browser 3D inspection of .kicad_pcb, .glb, .gltf, .stl, .obj and .wrl files +- Customer files are never used to train models; routing runs on dedicated offline hardware + +## Pages +${publicPaths().map(([p]) => `- ${config.baseUrl}${p}`).join('\n')} +`); + }); + + app.use((req, res) => render(req, res, 'site/404.html', {}, 404)); + + // eslint-disable-next-line no-unused-vars + app.use((err, req, res, _next) => { + const status = err.status || err.statusCode || (err.code === 'LIMIT_FILE_SIZE' ? 413 : 500); + if (status >= 500) console.error(err); + const message = + err.code === 'LIMIT_FILE_SIZE' + ? `File is larger than the ${config.maxUploadMb} MB limit.` + : status >= 500 + ? 'Internal server error.' + : err.message; + if (req.path.startsWith('/api')) res.status(status).json({ error: message }); + else res.status(status).type('text').send(message); + }); + + return { app, db, close: () => db.close() }; +} + +/** Per-page template variables. */ +function pageVars(route) { + if (route === '/use-cases' || route === '/') { + return { + useCaseCards: USE_CASES.map( + (u) => ` + ${esc(u.standard)} +

${esc(u.name)}

+

${esc(u.intro)}

+ Explore ${esc(u.name.toLowerCase())} +
`, + ).join(''), + }; + } + return {}; +} + +function useCaseVars(u) { + const others = USE_CASES.filter((x) => x.slug !== u.slug); + return { + __meta: { + title: `${u.title} | QodeX PCB`, + description: `${u.headline} ${u.intro}`.slice(0, 300), + }, + ucName: u.name, + ucTitle: u.title, + ucHeadline: u.headline, + ucIntro: u.intro, + ucStandard: u.standard, + ucSlug: u.slug, + ucChallenges: u.challenges.map(([t, b], i) => `
0${i + 1}

${esc(t)}

${esc(b)}

`).join(''), + ucApproach: u.approach.map((a) => `
  • ${esc(a)}
  • `).join(''), + ucSpecs: u.specs.map(([k, v]) => `
    ${esc(k)}
    ${esc(v)}
    `).join(''), + ucFaq: u.faq.map(([q, a]) => `
    ${esc(q)}

    ${esc(a)}

    `).join(''), + ucFaqJson: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: u.faq.map(([q, a]) => ({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: a } })), + }).replace(/ `${esc(o.name)}`).join(''), + }; +} + +async function ensureAdmin(db, admin) { + if (!admin.email || !admin.password) return; + const existing = db.prepare('SELECT id, role FROM users WHERE email = ?').get(admin.email.toLowerCase()); + if (existing) { + if (existing.role !== 'admin') db.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(existing.id); + return; + } + db.prepare("INSERT INTO users (email, name, password_hash, role) VALUES (?, ?, ?, 'admin')").run( + admin.email.toLowerCase(), + admin.name, + await hashPassword(admin.password), + ); + console.log(`[qodex-pcb] admin account created: ${admin.email}`); +} diff --git a/pcb-portal/server/auth.js b/pcb-portal/server/auth.js new file mode 100644 index 0000000..d2566f1 --- /dev/null +++ b/pcb-portal/server/auth.js @@ -0,0 +1,122 @@ +import { createHash, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; + +const scrypt = promisify(scryptCb); +export const SESSION_COOKIE = 'qx_sid'; +const SESSION_DAYS = 30; + +export async function hashPassword(password) { + const salt = randomBytes(16); + const key = await scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }); + return `scrypt$${salt.toString('base64')}$${key.toString('base64')}`; +} + +export async function verifyPassword(password, stored) { + const [alg, saltB64, keyB64] = String(stored).split('$'); + if (alg !== 'scrypt' || !saltB64 || !keyB64) return false; + const expected = Buffer.from(keyB64, 'base64'); + const key = await scrypt(password, Buffer.from(saltB64, 'base64'), expected.length, { N: 16384, r: 8, p: 1 }); + return timingSafeEqual(key, expected); +} + +const sha256 = (s) => createHash('sha256').update(s).digest('hex'); + +export function createSession(db, userId) { + const token = randomBytes(32).toString('base64url'); + const expires = new Date(Date.now() + SESSION_DAYS * 864e5).toISOString(); + db.prepare('INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)').run(sha256(token), userId, expires); + return { token, expires }; +} + +export function destroySession(db, token) { + if (token) db.prepare('DELETE FROM sessions WHERE token_hash = ?').run(sha256(token)); +} + +export function parseCookies(header) { + const out = {}; + for (const part of String(header || '').split(';')) { + const i = part.indexOf('='); + if (i < 0) continue; + const k = part.slice(0, i).trim(); + if (!k) continue; + try { + out[k] = decodeURIComponent(part.slice(i + 1).trim()); + } catch { + out[k] = part.slice(i + 1).trim(); + } + } + return out; +} + +export function sessionCookie(token, { secure, expires }) { + const parts = [`${SESSION_COOKIE}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax']; + if (secure) parts.push('Secure'); + if (expires) parts.push(`Expires=${new Date(expires).toUTCString()}`); + else parts.push('Max-Age=0'); + return parts.join('; '); +} + +/** Attaches req.user (or null) from the session cookie. */ +export function sessionMiddleware(db) { + const find = db.prepare(` + SELECT u.id, u.email, u.name, u.phone, u.company, u.country, u.role, s.expires_at + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ?`); + const purge = db.prepare("DELETE FROM sessions WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ','now')"); + let lastPurge = 0; + return (req, _res, next) => { + if (Date.now() - lastPurge > 3600e3) { + purge.run(); + lastPurge = Date.now(); + } + const token = parseCookies(req.headers.cookie)[SESSION_COOKIE]; + req.sessionToken = token || null; + req.user = null; + if (token) { + const row = find.get(sha256(token)); + if (row && new Date(row.expires_at) > new Date()) { + delete row.expires_at; + req.user = row; + } + } + next(); + }; +} + +export function requireUser(req, res, next) { + if (!req.user) return res.status(401).json({ error: 'Please sign in to continue.' }); + next(); +} + +export function requireAdmin(req, res, next) { + if (!req.user) return res.status(401).json({ error: 'Please sign in to continue.' }); + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Administrator access required.' }); + next(); +} + +/** + * CSRF guard for cookie-authenticated APIs: every state-changing request must + * carry a custom header, which a cross-site form/navigation cannot set without a + * CORS preflight (and we never answer preflights). + */ +export function csrfGuard(req, res, next) { + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + if (req.get('x-qodex-request') !== '1') return res.status(403).json({ error: 'Invalid request (missing CSRF header).' }); + next(); +} + +/** Tiny fixed-window rate limiter keyed by IP + bucket. */ +export function rateLimit({ windowMs, max, bucket }) { + const hits = new Map(); + return (req, res, next) => { + const now = Date.now(); + const key = `${bucket}:${req.ip}`; + let h = hits.get(key); + if (!h || now - h.start > windowMs) h = { start: now, count: 0 }; + h.count++; + hits.set(key, h); + if (hits.size > 10000) for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); + if (h.count > max) return res.status(429).json({ error: 'Too many requests. Please try again in a few minutes.' }); + next(); + }; +} diff --git a/pcb-portal/server/config.js b/pcb-portal/server/config.js new file mode 100644 index 0000000..18ff7aa --- /dev/null +++ b/pcb-portal/server/config.js @@ -0,0 +1,74 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** All prices are stored and charged in US dollars (integer cents). */ +export const CURRENCY = 'USD'; + +export const PAYMENT_PROVIDERS = ['stripe', 'paypal', 'mock']; + +/** Minimal .env loader (KEY=VALUE, # comments, optional quotes). Real env wins. */ +export function loadDotEnv(file = join(ROOT, '.env')) { + if (!existsSync(file)) return; + for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq < 0) continue; + const key = line.slice(0, eq).trim(); + let val = line.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1); + if (!(key in process.env)) process.env[key] = val; + } +} + +const bool = (v, d = false) => (v === undefined || v === '' ? d : /^(1|true|yes|on)$/i.test(v)); + +export function readConfig(env = process.env, overrides = {}) { + const port = Number(env.PORT || 8787); + const baseUrl = (env.PUBLIC_BASE_URL || `http://localhost:${port}`).replace(/\/+$/, ''); + const providers = String(env.PAYMENT_PROVIDERS || env.PAYMENT_PROVIDER || 'mock') + .split(',') + .map((p) => p.trim().toLowerCase()) + .filter(Boolean); + + const cfg = { + port, + host: env.HOST || '0.0.0.0', + baseUrl, + dataDir: resolve(env.DATA_DIR || join(ROOT, 'data')), + maxUploadMb: Number(env.MAX_UPLOAD_MB || 200), + secureCookies: bool(env.SECURE_COOKIES, baseUrl.startsWith('https://')), + trustProxy: bool(env.TRUST_PROXY, false), + currency: CURRENCY, + siteName: env.SITE_NAME || 'QodeX PCB', + companyName: env.COMPANY_NAME || 'QodeX Systems Lab', + supportEmail: env.SUPPORT_EMAIL || '', + admin: { email: env.ADMIN_EMAIL || '', password: env.ADMIN_PASSWORD || '', name: env.ADMIN_NAME || 'QodeX Admin' }, + payment: { + providers, + stripe: { + secretKey: env.STRIPE_SECRET_KEY || '', + webhookSecret: env.STRIPE_WEBHOOK_SECRET || '', + }, + paypal: { + clientId: env.PAYPAL_CLIENT_ID || '', + clientSecret: env.PAYPAL_CLIENT_SECRET || '', + sandbox: bool(env.PAYPAL_SANDBOX, false), + }, + }, + ...overrides, + }; + + if (!providers.length) throw new Error('PAYMENT_PROVIDERS must list at least one of: stripe, paypal, mock'); + for (const p of providers) { + if (!PAYMENT_PROVIDERS.includes(p)) throw new Error(`Unknown payment provider "${p}" (allowed: ${PAYMENT_PROVIDERS.join(', ')})`); + } + if (providers.includes('stripe') && !cfg.payment.stripe.secretKey) throw new Error('STRIPE_SECRET_KEY is required when stripe is enabled'); + if (providers.includes('paypal') && (!cfg.payment.paypal.clientId || !cfg.payment.paypal.clientSecret)) { + throw new Error('PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET are required when paypal is enabled'); + } + return cfg; +} diff --git a/pcb-portal/server/content.js b/pcb-portal/server/content.js new file mode 100644 index 0000000..c9cbae6 --- /dev/null +++ b/pcb-portal/server/content.js @@ -0,0 +1,192 @@ +// Structured content for the programmatic use-case pages (/use-cases/:slug). + +export const USE_CASES = [ + { + slug: 'rf-microwave', + name: 'RF & microwave', + menu: 'Controlled-impedance feeds, via fences, IPC-2141A', + standard: 'IPC-2141A', + title: 'RF & microwave PCB routing', + headline: 'Every millimetre of transmission line, accounted for.', + intro: + 'RF boards fail quietly: a stub that resonates, a ground return that detours around a split, a coplanar gap that drifts with the etch. We route RF feeds as transmission lines first and copper second — solved against your actual stackup, not a default.', + challenges: [ + ['Impedance drift across layers', 'Microstrip, stripline and grounded coplanar geometries are solved per layer from the dielectric heights and εr in your stackup, then etch-compensated for the target fab.'], + ['Return-path discontinuities', 'Every RF net is checked against the reference plane beneath it; plane splits and anti-pad voids under a feed are flagged and routed around.'], + ['Isolation between chains', 'Via fences are stitched along feeds at a pitch derived from the highest frequency of interest, with keep-outs respected around matching networks.'], + ], + approach: [ + 'Stackup-aware impedance solve (IPC-2141A closed-form, verified against field-solver tables)', + 'Mitred / curved bends only — no 90° corners on RF nets', + 'Via fence and ground-stitch generation with configurable pitch', + 'Matching-network footprints and antenna keep-outs locked, never moved', + ], + specs: [ + ['Single-ended target', '50 Ω ±10 %'], + ['Differential target', '90 / 100 Ω ±10 %'], + ['Bend geometry', 'Mitred or arc'], + ['Fence pitch', '≤ λ/20 at f_max'], + ], + faq: [ + ['Do you support Rogers or hybrid stackups?', 'Yes. Give us the material and thicknesses per layer (or select them in KiCad’s stackup editor) and impedance is solved from those values.'], + ['Can you keep my hand-tuned matching network?', 'Lock the footprints and tracks in KiCad (or list them in the order notes). Locked items are carried through byte-for-byte.'], + ], + }, + { + slug: 'high-speed-digital', + name: 'High-speed digital', + menu: 'DDR, PCIe, USB and length-matched buses', + standard: 'IPC-2141A · JEDEC', + title: 'High-speed digital PCB routing — DDR, PCIe, USB', + headline: 'Length-matched, skew-bounded, return-path clean.', + intro: + 'DDR byte lanes, PCIe lanes and USB pairs are routed as constrained groups: impedance, intra-pair skew and inter-lane length windows are solved together, then verified before anything is written back to your board.', + challenges: [ + ['Byte-lane length windows', 'Data, strobe and mask signals are matched within the window you set, with serpentines placed where they do not couple into neighbours.'], + ['Intra-pair skew', 'Differential pairs are phase-matched at the bend where the skew is created, not only at the end of the run.'], + ['Layer transitions', 'Reference-plane changes at vias get stitching vias placed next to the signal via so the return current has a short path.'], + ], + approach: [ + 'Net classes and diff-pair rules read directly from your KiCad project', + 'Constraint groups for DDR byte lanes, address/command fly-by and PCIe lanes', + 'Skew and length verified by a post-route checker before delivery', + 'Evidence dossier lists every constrained net with its achieved length and skew', + ], + specs: [ + ['Pair skew target', 'user-defined (e.g. < 1.5 ps)'], + ['Length window', 'user-defined per group'], + ['Diff impedance', '85 / 90 / 100 Ω'], + ['Stitching', 'at every reference change'], + ], + faq: [ + ['Which DDR generations?', 'Routing is constraint-driven, so the same pipeline handles LPDDR4/4X, DDR4 and DDR5 topologies as long as the constraints are provided.'], + ['Will you change my placement?', 'No. Placement is treated as your design intent. If a constraint is unreachable with the current placement, we report it instead of moving parts.'], + ], + }, + { + slug: 'power-electronics', + name: 'Power electronics', + menu: 'Heavy copper, current density, thermal relief', + standard: 'IPC-2152', + title: 'Power electronics PCB layout — current density & thermal', + headline: 'Copper sized for the current, not for the grid.', + intro: + 'High-current paths are widened from the current you specify and the copper weight you order, using IPC-2152 temperature-rise data. Switching loops are kept tight and gate-drive paths short.', + challenges: [ + ['Conductor sizing', 'Trace and pour widths are derived from current, copper weight and allowed temperature rise, per IPC-2152.'], + ['Hot-loop area', 'Input capacitor, switch and diode/sync-FET loops are routed on adjacent layers to minimise loop inductance.'], + ['Via current capacity', 'Via arrays are sized for the current they carry and placed to spread heat into internal planes.'], + ], + approach: [ + 'Per-net current annotations from your notes or net-class names', + 'Thermal via arrays under power pads', + 'Creepage and clearance checks for high-voltage net classes', + '1 oz to 3 oz copper fabrication options', + ], + specs: [ + ['Sizing basis', 'IPC-2152'], + ['Copper weight', '1 – 3 oz'], + ['HV clearance', 'per net class'], + ['Thermal vias', 'auto-arrayed'], + ], + faq: [ + ['How do I tell you the current per net?', 'Name the net class (e.g. “PWR_10A”) or list the nets and currents in the order notes.'], + ['Do you do creepage for mains?', 'Clearance and creepage rules are enforced from your net classes; certification remains your responsibility.'], + ], + }, + { + slug: 'mixed-signal', + name: 'Mixed-signal', + menu: 'ADC/DAC partitioning and quiet grounds', + standard: 'IPC-2221', + title: 'Mixed-signal PCB routing — partitioning and grounding', + headline: 'Quiet analog, fast digital, one solid ground.', + intro: + 'Mixed-signal boards need partitioning by placement and discipline in routing: digital return currents must never cross under the analog front end. We route with the return path as a first-class constraint.', + challenges: [ + ['Return currents under analog', 'Digital nets are kept out of the analog region on every layer, and their return paths are checked against the plane beneath them.'], + ['Reference and clock hygiene', 'Clock and reference nets are guarded and routed with minimum length and layer changes.'], + ['Decoupling placement', 'Decoupling capacitors are connected with the shortest via-in-pad or via-adjacent path the fab allows.'], + ], + approach: [ + 'Region keep-outs derived from rule areas in your KiCad board', + 'Guard traces and stitching for sensitive nets', + 'Solid, unsplit ground by default — splits only where you draw them', + 'Report of every net crossing a region boundary', + ], + specs: [ + ['Ground strategy', 'solid reference'], + ['Guarding', 'per net class'], + ['Region rules', 'from KiCad rule areas'], + ['Cross-region report', 'included'], + ], + faq: [ + ['Do you split AGND and DGND?', 'Only if your design calls for it. The default is a solid plane with partitioned placement, which is what most modern converters recommend.'], + ['Can I mark nets as sensitive?', 'Yes — use a net class, or list them in the order notes.'], + ], + }, + { + slug: 'flex-rigid', + name: 'Flex & rigid-flex', + menu: 'Bend-aware routing, IPC-2223', + standard: 'IPC-2223', + title: 'Flex and rigid-flex PCB routing — IPC-2223', + headline: 'Routed to bend, not to break.', + intro: + 'In the flex region copper must follow the rules of a moving part: perpendicular to the bend, curved corners, staggered layers and no vias. We route flex zones with those rules applied automatically.', + challenges: [ + ['Bend-zone copper', 'Traces cross bend lines at 90°, with arcs instead of corners and staggered positions on adjacent layers.'], + ['Transitions', 'Rigid-to-flex transitions get teardrops and keep vias outside the stiffener boundary.'], + ['Hatched planes', 'Planes in the flex region are cross-hatched to preserve flexibility.'], + ], + approach: [ + 'Bend lines and flex regions read from KiCad user layers / rule areas', + 'No vias or pads inside dynamic bend zones', + 'Automatic teardrops on every flex-region pad', + 'Coverlay openings checked against the fab’s registration tolerance', + ], + specs: [ + ['Standard', 'IPC-2223'], + ['Corners in flex', 'arcs only'], + ['Vias in bend zone', 'none'], + ['Planes in flex', 'hatched'], + ], + faq: [ + ['Is flex available now?', 'Flex and rigid-flex routing is available on request — mention it in the order notes and we will confirm feasibility before you pay.'], + ['Static or dynamic flex?', 'Both. Tell us the bend radius and number of cycles and we pick the rules accordingly.'], + ], + }, + { + slug: 'hdi-microvia', + name: 'HDI & microvia', + menu: 'Fine-pitch BGA escape, stacked microvias', + standard: 'IPC-2226', + title: 'HDI PCB routing — BGA escape and microvias', + headline: 'Fine-pitch BGA escape, solved rather than fought.', + intro: + 'Dense BGAs are an escape-routing problem before they are a routing problem. We solve the fan-out pattern ring by ring using the via technology your fab supports — through, blind, buried or laser microvias.', + challenges: [ + ['Escape ordering', 'Outer rings escape on the top layer; inner rings drop through dog-bone or via-in-pad to lower layers in a planned order.'], + ['Microvia aspect ratio', 'Microvia and capture-pad sizes follow the aspect ratio and registration limits of the selected fab.'], + ['Via-in-pad', 'When required, via-in-pad is marked for filling and capping in the fabrication notes.'], + ], + approach: [ + 'Via technology chosen from your fab profile (through / blind / buried / micro)', + 'Ring-by-ring escape planning for BGAs down to 0.4 mm pitch', + 'Pad-entry teardrops and acid-trap removal at every fan-out', + 'Fab notes for filled and capped via-in-pad', + ], + specs: [ + ['Standard', 'IPC-2226'], + ['BGA pitch', 'down to 0.4 mm'], + ['Via types', 'through, blind, buried, micro'], + ['Via-in-pad', 'filled & capped'], + ], + faq: [ + ['Which fabs support microvias?', 'Most HDI-capable fabs do; select yours in the order form and the capability limits of that fab are applied.'], + ['Does HDI cost more?', 'Routing price is driven by nets, pads and layers. Fabrication cost for HDI is quoted separately by the fab.'], + ], + }, +]; + +export const USE_CASE_BY_SLUG = new Map(USE_CASES.map((u) => [u.slug, u])); diff --git a/pcb-portal/server/db.js b/pcb-portal/server/db.js new file mode 100644 index 0000000..0201806 --- /dev/null +++ b/pcb-portal/server/db.js @@ -0,0 +1,142 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + name TEXT NOT NULL, + phone TEXT, + company TEXT, + country TEXT, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('customer','admin')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + service TEXT NOT NULL, + specs TEXT NOT NULL, -- JSON: layers, size, qty, finish, ... + board_meta TEXT, -- JSON summary parsed from the uploaded .kicad_pcb + notes TEXT, + price INTEGER, -- minor units of currency (NULL = awaiting quote) + currency TEXT NOT NULL, + price_breakdown TEXT, -- JSON + price_is_manual INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + admin_note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + paid_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); + +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, -- random, unguessable + order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + uploader_id INTEGER NOT NULL REFERENCES users(id), + source TEXT NOT NULL CHECK (source IN ('customer','admin')), + original_name TEXT NOT NULL, + stored_name TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + viewer_kind TEXT, -- kicad_pcb | glb | gltf | stl | obj | wrl | NULL + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_files_order ON files(order_id); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + is_admin INTEGER NOT NULL DEFAULT 0, + body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_messages_order ON messages(order_id); + +CREATE TABLE IF NOT EXISTS payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_id TEXT NOT NULL UNIQUE, + order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','paid','failed')), + authority TEXT, -- gateway token / session id + ref_id TEXT, -- gateway receipt reference + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + paid_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_payments_order ON payments(order_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_authority ON payments(provider, authority) WHERE authority IS NOT NULL; + +CREATE TABLE IF NOT EXISTS order_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + actor_id INTEGER, + type TEXT NOT NULL, + data TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_events_order ON order_events(order_id); + +CREATE TABLE IF NOT EXISTS inquiries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + company TEXT, + topic TEXT NOT NULL, + message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','handled')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +`; + +export function openDb(dataDir) { + mkdirSync(dataDir, { recursive: true }); + const db = new Database(join(dataDir, 'qodex-pcb.sqlite')); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.pragma('busy_timeout = 5000'); + db.exec(SCHEMA); + return db; +} + +export function getSetting(db, key, fallback) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + if (!row) return fallback; + try { + return JSON.parse(row.value); + } catch { + return fallback; + } +} + +export function setSetting(db, key, value) { + db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, JSON.stringify(value)); +} + +export function logEvent(db, orderId, actorId, type, data) { + db.prepare('INSERT INTO order_events (order_id, actor_id, type, data) VALUES (?, ?, ?, ?)').run(orderId, actorId ?? null, type, data ? JSON.stringify(data) : null); +} diff --git a/pcb-portal/server/index.js b/pcb-portal/server/index.js new file mode 100644 index 0000000..ee2efa8 --- /dev/null +++ b/pcb-portal/server/index.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { loadDotEnv, readConfig } from './config.js'; +import { createApp } from './app.js'; + +loadDotEnv(); +const config = readConfig(); +const { app } = await createApp(config); + +app.listen(config.port, config.host, () => { + console.log(`[qodex-pcb] ${config.siteName} listening on ${config.baseUrl} (payments: ${config.payment.providers.join(', ')}, currency: ${config.currency})`); + if (config.payment.providers.includes('mock')) { + console.log('[qodex-pcb] The mock test checkout is enabled — no real money is charged. Disable it in production.'); + } +}); diff --git a/pcb-portal/server/layout.js b/pcb-portal/server/layout.js new file mode 100644 index 0000000..2b99adc --- /dev/null +++ b/pcb-portal/server/layout.js @@ -0,0 +1,188 @@ +// Server-side page layout. Pages live in /views as HTML fragments that start +// with a metadata comment: +// +// +// +// `shell` is "site" (marketing chrome), "app" (portal/admin sidebar) or "bare". +// Fragments may use {{name}} (HTML-escaped) and {{{name}}} (raw) placeholders. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { ROOT } from './config.js'; +import { USE_CASES } from './content.js'; + +const VIEWS = join(ROOT, 'views'); + +export const esc = (s) => + String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +const cache = new Map(); +function loadView(file) { + if (cache.has(file) && process.env.NODE_ENV === 'production') return cache.get(file); + const text = readFileSync(join(VIEWS, file), 'utf8'); + const m = /^\s*/.exec(text); + const view = { meta: m ? JSON.parse(m[1]) : {}, body: m ? text.slice(m[0].length) : text }; + cache.set(file, view); + return view; +} + +function fill(template, vars) { + return template + .replace(/\{\{\{(\w+)\}\}\}/g, (_, k) => (vars[k] ?? '').toString()) + .replace(/\{\{(\w+)\}\}/g, (_, k) => esc(vars[k])); +} + +const ICON = { + logo: '', + menu: '', +}; + +function siteHeader(user, active) { + const a = (href, label, key) => `${esc(label)}`; + const useCases = USE_CASES.map((u) => `${esc(u.name)}${esc(u.menu)}`).join(''); + const account = user + ? `Portal${user.role === 'admin' ? 'Admin' : ''}` + : `Start an order`; + return ` +`; +} + +function siteFooter(config) { + const year = new Date().getFullYear(); + const col = (title, links) => `

    ${esc(title)}

    ${links.map(([h, l]) => `${esc(l)}`).join('')}
    `; + return ` +
    + + +
    `; +} + +function appSidebar(user, active) { + const link = (href, label, key, icon) => + `${esc(label)}`; + const admin = + user?.role === 'admin' + ? `
    Operations
    + ${link('/admin', 'Order queue', 'admin', '▤')} + ${link('/admin/customers', 'Customers', 'admin-customers', '◎')} + ${link('/admin/inquiries', 'Inquiries', 'admin-inquiries', '✉')} + ${link('/admin/settings', 'Pricing', 'admin-settings', '$')}` + : ''; + return ` +`; +} + +/** + * Render a view into a full HTML document. + * opts: { user, config, path, vars, importMapTag, status } + */ +export function renderView(file, { user, config, path, vars = {}, importMapTag }) { + const view = loadView(file); + const meta = { ...view.meta, ...(vars.__meta || {}) }; + const title = meta.title ? fill(meta.title, vars) : config.siteName; + const description = meta.description ? fill(meta.description, vars) : ''; + const canonical = `${config.baseUrl}${path}`; + const body = fill(view.body, { ...vars, siteName: config.siteName, supportEmail: config.supportEmail || 'hello@example.com' }); + const shell = meta.shell || 'site'; + const robots = meta.robots || (shell === 'site' ? 'index,follow' : 'noindex,nofollow'); + const jsonLd = meta.jsonLd ? `` : ''; + const script = meta.script ? `` : ''; + const userJson = JSON.stringify(user ? { name: user.name, email: user.email, role: user.role } : null).replace(/
    ${appSidebar(user, meta.nav)}
    ${body}
    `; + } else if (shell === 'bare') { + content = `${siteHeader(user, meta.nav)}
    ${body}
    `; + } else { + content = `${siteHeader(user, meta.nav)}
    ${body}
    ${siteFooter(config)}`; + } + + return ` + + + + +${esc(title)} +${description ? `` : ''} + + + + + +${description ? `` : ''} + + + + + + + + + +${importMapTag} + +${jsonLd} + +${script} + + + +${content} + +`; +} + +function fillDeep(v, vars) { + if (typeof v === 'string') return v.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? '')); + if (Array.isArray(v)) return v.map((x) => fillDeep(x, vars)); + if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fillDeep(x, vars)])); + return v; +} diff --git a/pcb-portal/server/payments/index.js b/pcb-portal/server/payments/index.js new file mode 100644 index 0000000..33654c2 --- /dev/null +++ b/pcb-portal/server/payments/index.js @@ -0,0 +1,30 @@ +import { stripeProvider } from './stripe.js'; +import { paypalProvider } from './paypal.js'; + +/** + * Development-only gateway: sends the customer to an internal test checkout + * page and never touches real money. + */ +function mockProvider() { + return { + name: 'mock', + label: 'Test checkout (development only)', + async create({ paymentPublicId }) { + return { authority: `mock_${paymentPublicId}`, redirectUrl: `/pay/mock/${paymentPublicId}` }; + }, + async verify({ query }) { + return query?.outcome === 'OK' ? { ok: true, refId: `MOCK-${Date.now().toString(36).toUpperCase()}` } : { ok: false, error: 'Test payment was cancelled.' }; + }, + }; +} + +/** Returns a Map(name → provider) for every enabled gateway, in configured order. */ +export function createPaymentProviders(paymentCfg, fetchImpl = globalThis.fetch) { + const map = new Map(); + for (const name of paymentCfg.providers) { + if (name === 'stripe') map.set(name, stripeProvider(paymentCfg.stripe, fetchImpl)); + else if (name === 'paypal') map.set(name, paypalProvider(paymentCfg.paypal, fetchImpl)); + else if (name === 'mock') map.set(name, mockProvider()); + } + return map; +} diff --git a/pcb-portal/server/payments/paypal.js b/pcb-portal/server/payments/paypal.js new file mode 100644 index 0000000..f092fcd --- /dev/null +++ b/pcb-portal/server/payments/paypal.js @@ -0,0 +1,97 @@ +// PayPal Checkout — Orders v2 REST API (redirect flow, capture on return). +export function paypalProvider({ clientId, clientSecret, sandbox }, fetchImpl = fetch) { + const api = sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + let token = null; + let tokenExpires = 0; + + async function accessToken() { + if (token && Date.now() < tokenExpires - 60_000) return token; + const res = await fetchImpl(`${api}/v1/oauth2/token`, { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials', + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || !json.access_token) throw new Error(`PayPal auth failed: ${json.error_description || res.status}`); + token = json.access_token; + tokenExpires = Date.now() + (Number(json.expires_in) || 300) * 1000; + return token; + } + + async function call(method, path, body, extraHeaders = {}) { + const res = await fetchImpl(`${api}${path}`, { + method, + headers: { Authorization: `Bearer ${await accessToken()}`, 'Content-Type': 'application/json', ...extraHeaders }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = await res.json().catch(() => ({})); + return { status: res.status, ok: res.ok, json }; + } + + const dollars = (cents) => (cents / 100).toFixed(2); + + function checkCapture(order, amount, currency, paymentPublicId) { + const unit = order?.purchase_units?.[0]; + const capture = unit?.payments?.captures?.[0]; + if (order?.status !== 'COMPLETED' || !capture || capture.status !== 'COMPLETED') { + return { ok: false, error: 'The PayPal payment was not completed.' }; + } + if (capture.amount?.currency_code !== currency || capture.amount?.value !== dollars(amount) || (unit.custom_id && unit.custom_id !== paymentPublicId)) { + return { ok: false, error: 'Amount or reference does not match the order.' }; + } + return { ok: true, refId: capture.id }; + } + + return { + name: 'paypal', + label: 'PayPal', + + async create({ amount, currency, description, returnUrl, cancelUrl, paymentPublicId, orderCode, brandName }) { + const { ok, json } = await call( + 'POST', + '/v2/checkout/orders', + { + intent: 'CAPTURE', + purchase_units: [ + { + reference_id: orderCode, + custom_id: paymentPublicId, + description: description.slice(0, 127), + amount: { currency_code: currency, value: dollars(amount) }, + }, + ], + payment_source: { + paypal: { + experience_context: { + brand_name: (brandName || 'QodeX PCB').slice(0, 127), + user_action: 'PAY_NOW', + shipping_preference: 'NO_SHIPPING', + return_url: returnUrl, + cancel_url: cancelUrl, + }, + }, + }, + }, + { 'PayPal-Request-Id': paymentPublicId }, + ); + const link = json?.links?.find((l) => l.rel === 'payer-action' || l.rel === 'approve'); + if (!ok || !json.id || !link) throw new Error(`PayPal order creation failed: ${json?.message || json?.name || 'unknown error'}`); + return { authority: json.id, redirectUrl: link.href }; + }, + + async verify({ authority, amount, currency, paymentPublicId, query = {} }) { + if (query.token && query.token !== authority) return { ok: false, error: 'PayPal token does not match this payment.' }; + const cap = await call('POST', `/v2/checkout/orders/${encodeURIComponent(authority)}/capture`, {}, { 'PayPal-Request-Id': `cap-${paymentPublicId}` }); + if (cap.ok) return checkCapture(cap.json, amount, currency, paymentPublicId); + // Already captured (e.g. the customer refreshed the return page): read it back. + if (cap.status === 422) { + const got = await call('GET', `/v2/checkout/orders/${encodeURIComponent(authority)}`); + if (got.ok) return checkCapture(got.json, amount, currency, paymentPublicId); + } + return { ok: false, error: cap.json?.details?.[0]?.description || cap.json?.message || 'PayPal capture failed.' }; + }, + }; +} diff --git a/pcb-portal/server/payments/service.js b/pcb-portal/server/payments/service.js new file mode 100644 index 0000000..b7636f9 --- /dev/null +++ b/pcb-portal/server/payments/service.js @@ -0,0 +1,100 @@ +// Payment orchestration shared by the checkout routes, gateway return URLs and +// the Stripe webhook. A payment is only marked paid after the gateway itself +// confirms it (server-to-server verify), and finalisation is idempotent. +import { randomBytes } from 'node:crypto'; +import { logEvent } from '../db.js'; +import { HttpError } from '../routes/shared.js'; + +export function createPaymentService(ctx) { + const { db, config, providers } = ctx; + + function provider(name) { + const p = providers.get(name); + if (!p) throw new HttpError(400, 'That payment method is not available.'); + return p; + } + + /** Create a checkout with the chosen gateway; returns { redirectUrl }. */ + async function start(order, user, providerName) { + const gw = provider(providerName || [...providers.keys()][0]); + const publicId = randomBytes(12).toString('base64url'); + const info = db + .prepare('INSERT INTO payments (public_id, order_id, provider, amount, currency) VALUES (?, ?, ?, ?, ?)') + .run(publicId, order.id, gw.name, order.price, order.currency); + let created; + try { + created = await gw.create({ + amount: order.price, + currency: order.currency, + description: `${config.siteName} order ${order.code} — ${order.title}`.slice(0, 250), + returnUrl: `${config.baseUrl}/api/payments/${gw.name}/return?pid=${publicId}`, + cancelUrl: `${config.baseUrl}/portal/orders/${order.code}?payment=cancelled`, + email: user.email, + paymentPublicId: publicId, + orderCode: order.code, + brandName: config.siteName, + }); + } catch (err) { + db.prepare("UPDATE payments SET status = 'failed', error = ? WHERE id = ?").run(String(err.message).slice(0, 500), info.lastInsertRowid); + console.error(`[payments] ${gw.name} create failed:`, err.message); + throw new HttpError(502, 'Could not reach the payment provider. Please try again.'); + } + db.prepare('UPDATE payments SET authority = ? WHERE id = ?').run(created.authority, info.lastInsertRowid); + logEvent(db, order.id, user.id, 'payment_started', { provider: gw.name, amount: order.price }); + return { redirectUrl: created.redirectUrl }; + } + + /** Verify with the gateway and record the outcome. Returns { order, ok }. */ + async function finalize(payment, query = {}) { + const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(payment.order_id); + const current = db.prepare('SELECT status FROM payments WHERE id = ?').get(payment.id); + if (current.status === 'paid') return { order, ok: true }; + if (current.status === 'failed') return { order, ok: false }; + let result; + try { + result = await provider(payment.provider).verify({ + authority: payment.authority, + amount: payment.amount, + currency: payment.currency, + paymentPublicId: payment.public_id, + query, + }); + } catch (err) { + result = { ok: false, error: err.message }; + } + let ok = false; + db.transaction(() => { + // Re-check inside the transaction so concurrent return + webhook calls + // record the payment exactly once. + const again = db.prepare('SELECT status FROM payments WHERE id = ?').get(payment.id); + if (again.status !== 'pending') { + ok = again.status === 'paid'; + return; + } + if (result.ok) { + ok = true; + db.prepare("UPDATE payments SET status = 'paid', ref_id = ?, paid_at = datetime('now') WHERE id = ?").run(result.refId || null, payment.id); + const fresh = db.prepare('SELECT status FROM orders WHERE id = ?').get(order.id); + if (fresh.status === 'awaiting_payment' || fresh.status === 'quote_pending') { + db.prepare("UPDATE orders SET status = 'paid', paid_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(order.id); + } else { + logEvent(db, order.id, null, 'payment_attention', { message: `Payment received while the order was "${fresh.status}". Manual review needed.` }); + } + logEvent(db, order.id, null, 'payment_succeeded', { provider: payment.provider, amount: payment.amount, refId: result.refId }); + } else { + db.prepare("UPDATE payments SET status = 'failed', error = ? WHERE id = ?").run(String(result.error || 'failed').slice(0, 500), payment.id); + logEvent(db, order.id, null, 'payment_failed', { provider: payment.provider, error: result.error }); + } + })(); + return { order, ok }; + } + + const byPublicId = (pid) => db.prepare('SELECT * FROM payments WHERE public_id = ?').get(String(pid || '')); + + return { + start, + finalize, + byPublicId, + methods: () => [...providers.values()].map((p) => ({ id: p.name, label: p.label })), + }; +} diff --git a/pcb-portal/server/payments/stripe.js b/pcb-portal/server/payments/stripe.js new file mode 100644 index 0000000..699a3f4 --- /dev/null +++ b/pcb-portal/server/payments/stripe.js @@ -0,0 +1,79 @@ +// Stripe Checkout (hosted payment page) via the REST API — no SDK needed. +// Cards, Apple Pay, Google Pay and Link are all offered by Checkout itself. +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export function stripeProvider({ secretKey, webhookSecret }, fetchImpl = fetch) { + async function call(method, path, params) { + const res = await fetchImpl(`https://api.stripe.com/v1${path}`, { + method, + headers: { + Authorization: `Bearer ${secretKey}`, + ...(params ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}), + }, + body: params ? new URLSearchParams(params).toString() : undefined, + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(`Stripe error: ${json?.error?.message || res.status}`); + return json; + } + + return { + name: 'stripe', + label: 'Card, Apple Pay, Google Pay (Stripe)', + + async create({ amount, currency, description, returnUrl, cancelUrl, email, paymentPublicId, orderCode }) { + const session = await call('POST', '/checkout/sessions', { + mode: 'payment', + success_url: returnUrl, + cancel_url: cancelUrl, + client_reference_id: paymentPublicId, + 'metadata[payment_id]': paymentPublicId, + 'metadata[order_code]': orderCode, + 'payment_intent_data[description]': description, + ...(email ? { customer_email: email } : {}), + 'line_items[0][quantity]': '1', + 'line_items[0][price_data][currency]': currency.toLowerCase(), + 'line_items[0][price_data][unit_amount]': String(amount), + 'line_items[0][price_data][product_data][name]': description, + }); + return { authority: session.id, redirectUrl: session.url }; + }, + + async verify({ authority, amount, currency, paymentPublicId }) { + const s = await call('GET', `/checkout/sessions/${encodeURIComponent(authority)}`); + if (s.payment_status !== 'paid') return { ok: false, error: 'The Stripe checkout was not completed.' }; + if (s.amount_total !== amount || String(s.currency).toUpperCase() !== currency || s.client_reference_id !== paymentPublicId) { + return { ok: false, error: 'Amount or reference does not match the order.' }; + } + return { ok: true, refId: String(s.payment_intent || s.id) }; + }, + + /** + * Validate a webhook delivery (Stripe-Signature: t=..,v1=..) and return the + * parsed event, or null when the signature is missing/invalid/stale. + */ + parseWebhook(rawBody, signatureHeader, toleranceSec = 300) { + if (!webhookSecret || !signatureHeader) return null; + const parts = Object.fromEntries( + String(signatureHeader) + .split(',') + .map((kv) => kv.split('=')) + .filter((kv) => kv.length === 2 && kv[0] === 't'), + ); + const t = Number(parts.t); + if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return null; + const expected = createHmac('sha256', webhookSecret).update(`${t}.${rawBody.toString('utf8')}`).digest(); + const candidates = String(signatureHeader) + .split(',') + .filter((kv) => kv.startsWith('v1=')) + .map((kv) => Buffer.from(kv.slice(3), 'hex')); + const ok = candidates.some((c) => c.length === expected.length && timingSafeEqual(c, expected)); + if (!ok) return null; + try { + return JSON.parse(rawBody.toString('utf8')); + } catch { + return null; + } + }, + }; +} diff --git a/pcb-portal/server/pricing.js b/pcb-portal/server/pricing.js new file mode 100644 index 0000000..9ad11a2 --- /dev/null +++ b/pcb-portal/server/pricing.js @@ -0,0 +1,215 @@ +// Order specification validation + quote calculation. +// +// Tariff numbers live in the `pricing` settings row (editable from the admin +// panel) and are expressed in US dollars. Final prices are stored as integer +// cents. + +export const SERVICES = { + routing: 'Routing + DFM polish', + routing_fab: 'Routing + fabrication', + dfm_review: 'DFM / DRC review', + fab_only: 'Fabrication only', +}; + +export const SERVICE_DETAILS = { + routing: { + summary: 'Full topological routing of your placed KiCad board: BGA fan-out, length and impedance matching, return-path aware planes and a DFM polish pass.', + deliverables: ['Routed .kicad_pcb (lossless round-trip)', 'GLB 3D model', 'Gerber + drill package', 'DFM / DRC evidence dossier (PDF)'], + }, + routing_fab: { + summary: 'Everything in Routing, then we release the board to the fabricator of your choice and ship the bare PCBs.', + deliverables: ['Everything in Routing', 'Fab-house CAM check', 'Bare boards shipped worldwide'], + }, + dfm_review: { + summary: 'Your board is already routed. We run a full DRC/DFM inspection against your fab house capability table and return an annotated report.', + deliverables: ['Annotated DFM / DRC report', 'Acid-trap, sliver and annular-ring findings', 'Fix list ordered by severity'], + }, + fab_only: { + summary: 'Production-ready files in, bare boards out. We sanity-check the package before it goes to the fab.', + deliverables: ['CAM sanity check', 'Bare boards shipped worldwide'], + }, +}; + +export const OPTIONS = { + layers: [1, 2, 4, 6, 8, 10, 12, 14, 16], + thickness: [0.4, 0.6, 0.8, 1.0, 1.2, 1.6, 2.0, 2.4], + copperOz: [1, 2, 3], + finish: { hasl: 'HASL', lf_hasl: 'Lead-free HASL', enig: 'ENIG', osp: 'OSP', immersion_silver: 'Immersion silver', hard_gold: 'Hard gold' }, + maskColor: { green: 'Green', black: 'Black', matte_black: 'Matte black', matte_green: 'Matte green', blue: 'Blue', red: 'Red', white: 'White', yellow: 'Yellow', purple: 'Purple' }, + silkColor: { white: 'White', black: 'Black' }, + turnaround: { standard: 'Standard', express: 'Express' }, + fabHouse: { any: 'QodeX selects', jlcpcb: 'JLCPCB', pcbway: 'PCBWay', eurocircuits: 'Eurocircuits', sierra: 'Sierra Circuits', other: 'Other (specify in notes)' }, +}; + +export function defaultPricing() { + // Starting tariffs — tune them in /admin/settings. + return { + autoQuote: true, + roundTo: 1, + minimum: 49, + services: { + routing: { base: 120, perLayer: 40, perCm2: 0.6, perNet: 0.15, perPad: 0.02, includesFab: false }, + routing_fab: { base: 120, perLayer: 40, perCm2: 0.6, perNet: 0.15, perPad: 0.02, includesFab: true }, + dfm_review: { base: 60, perLayer: 10, perCm2: 0.1, perNet: 0, perPad: 0, includesFab: false }, + fab_only: { base: 0, perLayer: 0, perCm2: 0, perNet: 0, perPad: 0, includesFab: true }, + }, + fab: { + setup: 25, + perCm2: 0.03, + layerFactor: { 1: 0.8, 2: 1, 4: 2.2, 6: 3.4, 8: 4.8, 10: 6.2, 12: 7.6, 14: 9, 16: 10.5 }, + copperOzFactor: { 1: 1, 2: 1.35, 3: 1.7 }, + finishExtra: { hasl: 0, lf_hasl: 0.05, enig: 0.25, osp: 0, immersion_silver: 0.15, hard_gold: 0.6 }, + colorExtra: 0.05, + }, + multipliers: { express: 1.5, impedanceControl: 1.2, bga: 1.25 }, + }; +} + +export class ValidationError extends Error { + constructor(message) { + super(message); + this.status = 400; + } +} + +const pick = (value, allowed, fallback) => (allowed.includes(value) ? value : fallback); + +/** Normalise and validate raw order specs from the client. */ +export function normalizeSpecs(raw = {}, boardMeta = null) { + const service = pick(raw.service, Object.keys(SERVICES), null); + if (!service) throw new ValidationError('Choose a service.'); + if (boardMeta) { + // Geometry parsed server-side from the uploaded board is authoritative: the + // client may not shrink the board, drop layers or under-report nets/pads. + raw = { + ...raw, + layers: Math.max(Number(raw.layers) || 0, boardMeta.copperLayerCount || 0), + widthMm: boardMeta.widthMm, + heightMm: boardMeta.heightMm, + nets: boardMeta.nets, + pads: boardMeta.pads, + }; + } + let layers = Number(raw.layers || 2); + if (!OPTIONS.layers.includes(layers)) { + // round odd layer counts up to the next manufacturable stack + layers = OPTIONS.layers.find((l) => l >= layers) ?? NaN; + } + if (!Number.isFinite(layers)) throw new ValidationError('Layer count must be between 1 and 16.'); + const widthMm = Number(raw.widthMm); + const heightMm = Number(raw.heightMm); + if (!(widthMm >= 3 && widthMm <= 1000) || !(heightMm >= 3 && heightMm <= 1000)) { + throw new ValidationError('Board dimensions must be between 3 and 1000 mm.'); + } + const quantity = Math.round(Number(raw.quantity ?? 5)); + if (!(quantity >= 1 && quantity <= 100000)) throw new ValidationError('Quantity must be between 1 and 100,000.'); + const thickness = Number(raw.thickness ?? 1.6); + if (!OPTIONS.thickness.includes(thickness)) throw new ValidationError('Invalid board thickness.'); + const copperOz = Number(raw.copperOz ?? 1); + if (!OPTIONS.copperOz.includes(copperOz)) throw new ValidationError('Invalid copper weight.'); + return { + service, + layers, + widthMm: +widthMm.toFixed(2), + heightMm: +heightMm.toFixed(2), + quantity, + thickness, + copperOz, + finish: pick(raw.finish, Object.keys(OPTIONS.finish), 'enig'), + maskColor: pick(raw.maskColor, Object.keys(OPTIONS.maskColor), 'green'), + silkColor: pick(raw.silkColor, Object.keys(OPTIONS.silkColor), 'white'), + turnaround: pick(raw.turnaround, Object.keys(OPTIONS.turnaround), 'standard'), + fabHouse: pick(raw.fabHouse, Object.keys(OPTIONS.fabHouse), 'any'), + impedanceControl: raw.impedanceControl === true || raw.impedanceControl === 'true' || raw.impedanceControl === 'on', + nets: clampInt(raw.nets, 0, 1e6), + pads: clampInt(raw.pads, 0, 1e7), + }; +} + +function clampInt(v, min, max) { + const n = Math.round(Number(v)); + return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : 0; +} + +const roundUp = (v, step) => (step > 0 ? Math.ceil(v / step - 1e-9) * step : v); + +/** + * Compute a quote. Returns { total (cents), currency, lines[], autoQuote }. + * `lines` amounts are in dollars for display. + */ +export function computeQuote(specs, pricing, boardMeta = null) { + const svc = pricing.services[specs.service]; + if (!svc) throw new ValidationError('Unknown service.'); + const areaCm2 = (specs.widthMm * specs.heightMm) / 100; + const lines = []; + let design = 0; + const parts = [ + ['Service base', svc.base], + [`${specs.layers} copper layers`, svc.perLayer * specs.layers], + [`Board area ${areaCm2.toFixed(1)} cm²`, svc.perCm2 * areaCm2], + [`${specs.nets} nets`, svc.perNet * specs.nets], + [`${specs.pads} pads`, svc.perPad * specs.pads], + ]; + for (const [label, amount] of parts) { + if (amount > 0) { + lines.push({ label, amount }); + design += amount; + } + } + if (design > 0 && specs.impedanceControl) { + const extra = design * (pricing.multipliers.impedanceControl - 1); + lines.push({ label: 'Controlled impedance', amount: extra }); + design += extra; + } + if (design > 0 && boardMeta?.hasBGA) { + const extra = design * (pricing.multipliers.bga - 1); + lines.push({ label: 'BGA escape routing', amount: extra }); + design += extra; + } + let fab = 0; + if (svc.includesFab) { + const f = pricing.fab; + const lf = f.layerFactor[specs.layers] ?? specs.layers * 0.7; + const boards = f.perCm2 * areaCm2 * specs.quantity * lf; + const cu = f.copperOzFactor[specs.copperOz] ?? 1; + const extras = (f.finishExtra[specs.finish] ?? 0) + (specs.maskColor === 'green' ? 0 : f.colorExtra); + fab = (f.setup + boards) * cu * (1 + extras); + lines.push({ label: `Fabrication, ${specs.quantity} pcs`, amount: fab }); + } + let subtotal = design + fab; + if (specs.turnaround === 'express') { + const extra = subtotal * (pricing.multipliers.express - 1); + lines.push({ label: 'Express turnaround', amount: extra }); + subtotal += extra; + } + const rounded = roundUp(subtotal, pricing.roundTo); + const dollars = Math.max(pricing.minimum, rounded); + if (dollars > rounded) lines.push({ label: 'Minimum order top-up', amount: dollars - subtotal }); + return { + total: Math.round(dollars * 100), + currency: 'USD', + lines: lines.map((l) => ({ label: l.label, amount: Math.round(l.amount * 100) / 100 })), + autoQuote: !!pricing.autoQuote, + }; +} + +/** Validate an admin-edited pricing sheet (shape + non-negative numbers). */ +export function validatePricing(p) { + const base = defaultPricing(); + const isNum = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0; + if (!p || typeof p !== 'object') throw new ValidationError('Invalid pricing sheet.'); + if (!isNum(p.minimum) || !isNum(p.roundTo)) throw new ValidationError('minimum and roundTo must be non-negative numbers.'); + for (const key of Object.keys(base.services)) { + const s = p.services?.[key]; + if (!s) throw new ValidationError(`Service "${key}" is missing from the pricing sheet.`); + for (const k of ['base', 'perLayer', 'perCm2', 'perNet', 'perPad']) if (!isNum(s[k])) throw new ValidationError(`services.${key}.${k} must be a non-negative number.`); + s.includesFab = !!s.includesFab; + } + if (!p.fab || !isNum(p.fab.setup) || !isNum(p.fab.perCm2) || !isNum(p.fab.colorExtra)) throw new ValidationError('The fab section is invalid.'); + for (const k of ['layerFactor', 'copperOzFactor', 'finishExtra']) { + if (!p.fab[k] || typeof p.fab[k] !== 'object' || !Object.values(p.fab[k]).every(isNum)) throw new ValidationError(`fab.${k} is invalid.`); + } + for (const k of ['express', 'impedanceControl', 'bga']) if (!isNum(p.multipliers?.[k])) throw new ValidationError(`multipliers.${k} must be a number.`); + p.autoQuote = !!p.autoQuote; + return p; +} diff --git a/pcb-portal/server/routes/admin.js b/pcb-portal/server/routes/admin.js new file mode 100644 index 0000000..ee69398 --- /dev/null +++ b/pcb-portal/server/routes/admin.js @@ -0,0 +1,157 @@ +import { Router } from 'express'; +import { requireAdmin } from '../auth.js'; +import { getSetting, logEvent, setSetting } from '../db.js'; +import { defaultPricing, validatePricing } from '../pricing.js'; +import { HttpError, STATUS_LABELS, asyncH, cleanText, cleanupTemp, orderDTO, orderDetail, storeUploads, uploader } from './shared.js'; + +const ORDER_JOIN = `SELECT o.*, u.name AS customer_name, u.email AS customer_email, u.phone AS customer_phone, u.company AS customer_company + FROM orders o JOIN users u ON u.id = o.user_id`; + +const sqlNow = () => new Date().toISOString().replace('T', ' ').slice(0, 19); + +export function adminRouter(ctx) { + const { db, config } = ctx; + const r = Router(); + const upload = uploader(ctx, 30); + r.use(requireAdmin); + + const findOrder = (code) => { + const o = db.prepare(`${ORDER_JOIN} WHERE o.code = ?`).get(String(code)); + if (!o) throw new HttpError(404, 'Order not found.'); + return o; + }; + + r.get('/stats', (_req, res) => { + const byStatus = Object.fromEntries(db.prepare('SELECT status, COUNT(*) AS n FROM orders GROUP BY status').all().map((x) => [x.status, x.n])); + const revenue = db.prepare("SELECT COALESCE(SUM(amount),0) AS s FROM payments WHERE status = 'paid'").get().s; + const revenue30 = db.prepare("SELECT COALESCE(SUM(amount),0) AS s FROM payments WHERE status = 'paid' AND paid_at >= datetime('now','-30 days')").get().s; + const customers = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'customer'").get().n; + const newInquiries = db.prepare("SELECT COUNT(*) AS n FROM inquiries WHERE status = 'new'").get().n; + res.json({ byStatus, revenue, revenue30, currency: config.currency, customers, newInquiries }); + }); + + r.get('/orders', (req, res) => { + const status = String(req.query.status || ''); + const q = cleanText(req.query.q, 100); + const where = []; + const params = []; + if (status && STATUS_LABELS[status]) { + where.push('o.status = ?'); + params.push(status); + } else if (status === 'open') { + where.push("o.status NOT IN ('completed','cancelled')"); + } + if (q) { + where.push('(o.code LIKE ? OR o.title LIKE ? OR u.email LIKE ? OR u.name LIKE ?)'); + params.push(...Array(4).fill(`%${q.replace(/[%_]/g, '')}%`)); + } + const rows = db.prepare(`${ORDER_JOIN} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY o.id DESC LIMIT 500`).all(...params); + res.json({ orders: rows.map(orderDTO) }); + }); + + r.get('/orders/:code', (req, res) => { + res.json(orderDetail(db, findOrder(req.params.code), { admin: true })); + }); + + /** Update price / status / internal note. */ + r.patch('/orders/:code', (req, res) => { + const o = findOrder(req.params.code); + const body = req.body || {}; + const updates = {}; + const paidOrLater = ['paid', 'in_progress', 'delivered', 'completed'].includes(o.status); + + if (body.price !== undefined && body.price !== null && body.price !== '') { + if (paidOrLater) throw new HttpError(409, 'The price of a paid order cannot be changed.'); + const dollars = Number(body.price); + if (!(dollars > 0)) throw new HttpError(400, 'Price must be greater than zero.'); + updates.price = Math.round(dollars * 100); + updates.price_is_manual = 1; + if (o.status === 'quote_pending' && body.status === undefined) updates.status = 'awaiting_payment'; + } + if (body.status !== undefined && body.status !== o.status) { + if (!STATUS_LABELS[body.status]) throw new HttpError(400, 'Unknown status.'); + const price = updates.price ?? o.price; + if (body.status === 'awaiting_payment' && !(price > 0)) throw new HttpError(400, 'Set a price first.'); + updates.status = body.status; + if (body.status === 'paid' && !o.paid_at) updates.paid_at = sqlNow(); + } + if (body.adminNote !== undefined) updates.admin_note = cleanText(body.adminNote, 5000) || null; + if (!Object.keys(updates).length) return res.json(orderDetail(db, o, { admin: true })); + + db.transaction(() => { + const sets = Object.keys(updates).map((k) => `${k} = @${k}`).join(', '); + db.prepare(`UPDATE orders SET ${sets}, updated_at = datetime('now') WHERE id = @id`).run({ ...updates, id: o.id }); + if (updates.price !== undefined && updates.price !== o.price) { + // a changed price invalidates any checkout that is still open + db.prepare("UPDATE payments SET status = 'failed', error = 'price changed by admin' WHERE order_id = ? AND status = 'pending'").run(o.id); + logEvent(db, o.id, req.user.id, 'price', { from: o.price, to: updates.price }); + } + if (updates.status) { + logEvent(db, o.id, req.user.id, 'status', { from: o.status, to: updates.status, ...(updates.status === 'paid' ? { manual: true } : {}) }); + } + })(); + res.json(orderDetail(db, findOrder(o.code), { admin: true })); + }); + + /** Upload deliverables (routed .kicad_pcb, .glb previews, gerbers, reports). */ + r.post('/orders/:code/files', upload.array('files', 30), asyncH(async (req, res) => { + try { + const o = findOrder(req.params.code); + if (!req.files?.length) throw new HttpError(400, 'No file selected.'); + const rows = await storeUploads(ctx, req.files, { orderId: o.id, uploaderId: req.user.id, source: 'admin', note: cleanText(req.body?.note, 500) }); + logEvent(db, o.id, req.user.id, 'files_added', { count: rows.length, source: 'admin', names: rows.map((x) => x.original_name) }); + if (req.body?.markDelivered === 'true' && ['paid', 'in_progress'].includes(o.status)) { + db.prepare("UPDATE orders SET status = 'delivered', updated_at = datetime('now') WHERE id = ?").run(o.id); + logEvent(db, o.id, req.user.id, 'status', { from: o.status, to: 'delivered' }); + } else { + db.prepare("UPDATE orders SET updated_at = datetime('now') WHERE id = ?").run(o.id); + } + res.status(201).json(orderDetail(db, findOrder(o.code), { admin: true })); + } finally { + await cleanupTemp(req.files); + } + })); + + r.delete('/files/:id', asyncH(async (req, res) => { + const f = db.prepare('SELECT * FROM files WHERE id = ?').get(String(req.params.id)); + if (!f) throw new HttpError(404, 'File not found.'); + db.prepare('DELETE FROM files WHERE id = ?').run(f.id); + await ctx.storage.remove(f.stored_name); + logEvent(db, f.order_id, req.user.id, 'file_deleted', { name: f.original_name }); + res.json({ ok: true }); + })); + + r.get('/customers', (_req, res) => { + const rows = db + .prepare(`SELECT u.id, u.email, u.name, u.phone, u.company, u.country, u.role, u.created_at, + COUNT(o.id) AS orders, + COALESCE(SUM(CASE WHEN o.status IN ('paid','in_progress','delivered','completed') THEN o.price END), 0) AS spent + FROM users u LEFT JOIN orders o ON o.user_id = u.id + GROUP BY u.id ORDER BY u.id DESC LIMIT 1000`) + .all(); + res.json({ customers: rows, currency: config.currency }); + }); + + r.get('/inquiries', (_req, res) => { + res.json({ inquiries: db.prepare('SELECT * FROM inquiries ORDER BY id DESC LIMIT 500').all() }); + }); + + r.patch('/inquiries/:id', (req, res) => { + const status = req.body?.status === 'handled' ? 'handled' : 'new'; + const info = db.prepare('UPDATE inquiries SET status = ? WHERE id = ?').run(status, Number(req.params.id)); + if (!info.changes) throw new HttpError(404, 'Inquiry not found.'); + res.json({ ok: true }); + }); + + r.get('/settings/pricing', (_req, res) => { + res.json({ pricing: getSetting(db, 'pricing'), currency: config.currency, defaults: defaultPricing() }); + }); + + r.put('/settings/pricing', (req, res) => { + const pricing = validatePricing(req.body?.pricing); + setSetting(db, 'pricing', pricing); + res.json({ pricing }); + }); + + return r; +} diff --git a/pcb-portal/server/routes/api.js b/pcb-portal/server/routes/api.js new file mode 100644 index 0000000..c8149d6 --- /dev/null +++ b/pcb-portal/server/routes/api.js @@ -0,0 +1,360 @@ +import { Router } from 'express'; +import { + createSession, + destroySession, + hashPassword, + rateLimit, + requireUser, + sessionCookie, + verifyPassword, +} from '../auth.js'; +import { getSetting, logEvent } from '../db.js'; +import { OPTIONS, SERVICES, SERVICE_DETAILS, computeQuote, normalizeSpecs } from '../pricing.js'; +import { + EMAIL_RE, + HttpError, + STATUS_LABELS, + asyncH, + boardMetaFromUploads, + cleanText, + cleanupTemp, + newOrderCode, + orderDTO, + orderDetail, + paymentDTO, + storeUploads, + uploader, +} from './shared.js'; + +export const INQUIRY_TOPICS = { + sales: 'Sales & enterprise', + engineering: 'Engineering question', + security: 'Security & compliance', + careers: 'Careers', + other: 'Other', +}; + +export function apiRouter(ctx) { + const { db, config, payments } = ctx; + const r = Router(); + const upload = uploader(ctx); + const authLimit = rateLimit({ windowMs: 10 * 60e3, max: 20, bucket: 'auth' }); + const contactLimit = rateLimit({ windowMs: 60 * 60e3, max: 10, bucket: 'contact' }); + + const userDTO = (u) => u && { id: u.id, email: u.email, name: u.name, phone: u.phone, company: u.company, country: u.country, role: u.role }; + const setCookie = (res, s) => res.setHeader('Set-Cookie', sessionCookie(s.token, { secure: config.secureCookies, expires: s.expires })); + + // ── public config ── + r.get('/config', (_req, res) => { + const pricing = getSetting(db, 'pricing'); + res.json({ + siteName: config.siteName, + currency: config.currency, + paymentMethods: payments.methods(), + maxUploadMb: config.maxUploadMb, + autoQuote: !!pricing?.autoQuote, + services: SERVICES, + serviceDetails: SERVICE_DETAILS, + options: OPTIONS, + statusLabels: STATUS_LABELS, + inquiryTopics: INQUIRY_TOPICS, + supportEmail: config.supportEmail, + }); + }); + + // ── auth ── + r.post('/auth/register', authLimit, asyncH(async (req, res) => { + const email = cleanText(req.body?.email, 200).toLowerCase(); + const name = cleanText(req.body?.name, 120); + const password = String(req.body?.password ?? ''); + if (!EMAIL_RE.test(email)) throw new HttpError(400, 'Enter a valid email address.'); + if (name.length < 2) throw new HttpError(400, 'Enter your name.'); + if (password.length < 8) throw new HttpError(400, 'Password must be at least 8 characters.'); + if (req.body?.acceptTerms !== true && req.body?.acceptTerms !== 'on') throw new HttpError(400, 'Please accept the Terms of Service and IP policy.'); + if (db.prepare('SELECT 1 FROM users WHERE email = ?').get(email)) throw new HttpError(409, 'An account with this email already exists. Sign in instead.'); + const info = db + .prepare('INSERT INTO users (email, name, phone, company, country, password_hash) VALUES (?, ?, ?, ?, ?, ?)') + .run( + email, + name, + cleanText(req.body?.phone, 30) || null, + cleanText(req.body?.company, 160) || null, + cleanText(req.body?.country, 60) || null, + await hashPassword(password), + ); + setCookie(res, createSession(db, info.lastInsertRowid)); + res.status(201).json({ user: userDTO(db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid)) }); + })); + + r.post('/auth/login', authLimit, asyncH(async (req, res) => { + const email = cleanText(req.body?.email, 200).toLowerCase(); + const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email); + const ok = user && (await verifyPassword(String(req.body?.password ?? ''), user.password_hash)); + if (!ok) throw new HttpError(401, 'Incorrect email or password.'); + setCookie(res, createSession(db, user.id)); + res.json({ user: userDTO(user) }); + })); + + r.post('/auth/logout', (req, res) => { + destroySession(db, req.sessionToken); + res.setHeader('Set-Cookie', sessionCookie('', { secure: config.secureCookies })); + res.json({ ok: true }); + }); + + r.get('/auth/me', (req, res) => res.json({ user: userDTO(req.user) })); + + r.patch('/auth/me', requireUser, asyncH(async (req, res) => { + const b = req.body || {}; + const name = cleanText(b.name ?? req.user.name, 120); + if (name.length < 2) throw new HttpError(400, 'Enter your name.'); + db.prepare('UPDATE users SET name = ?, phone = ?, company = ?, country = ? WHERE id = ?').run( + name, + cleanText(b.phone ?? req.user.phone, 30) || null, + cleanText(b.company ?? req.user.company, 160) || null, + cleanText(b.country ?? req.user.country, 60) || null, + req.user.id, + ); + if (b.newPassword) { + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); + if (!(await verifyPassword(String(b.currentPassword ?? ''), u.password_hash))) throw new HttpError(400, 'Current password is incorrect.'); + if (String(b.newPassword).length < 8) throw new HttpError(400, 'New password must be at least 8 characters.'); + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(await hashPassword(String(b.newPassword)), req.user.id); + // sign out every other session + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.user.id); + setCookie(res, createSession(db, req.user.id)); + } + res.json({ user: userDTO(db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id)) }); + })); + + // ── public: quote estimator + contact ── + r.post('/quote', (req, res) => { + const pricing = getSetting(db, 'pricing'); + const meta = req.body?.boardMeta && typeof req.body.boardMeta === 'object' ? { hasBGA: !!req.body.boardMeta.hasBGA } : null; + const specs = normalizeSpecs(req.body?.specs || {}, null); + res.json({ specs, quote: computeQuote(specs, pricing, meta) }); + }); + + r.post('/contact', contactLimit, (req, res) => { + const b = req.body || {}; + // honeypot field: real users never fill it + if (b.website) return res.status(201).json({ ok: true }); + const name = cleanText(b.name, 120); + const email = cleanText(b.email, 200).toLowerCase(); + const message = cleanText(b.message, 8000); + const topic = INQUIRY_TOPICS[b.topic] ? b.topic : 'other'; + if (name.length < 2) throw new HttpError(400, 'Enter your name.'); + if (!EMAIL_RE.test(email)) throw new HttpError(400, 'Enter a valid email address.'); + if (message.length < 10) throw new HttpError(400, 'Tell us a little more (at least 10 characters).'); + db.prepare('INSERT INTO inquiries (name, email, company, topic, message) VALUES (?, ?, ?, ?, ?)').run(name, email, cleanText(b.company, 160) || null, topic, message); + res.status(201).json({ ok: true }); + }); + + // ── orders (customer) ── + const findOwnOrder = (req) => { + const o = db.prepare('SELECT * FROM orders WHERE code = ?').get(String(req.params.code)); + if (!o || (o.user_id !== req.user.id && req.user.role !== 'admin')) throw new HttpError(404, 'Order not found.'); + return o; + }; + const detail = (req, o) => orderDetail(db, o, { admin: req.user.role === 'admin' }); + const touch = (id) => db.prepare("UPDATE orders SET updated_at = datetime('now') WHERE id = ?").run(id); + + r.get('/orders', requireUser, (req, res) => { + const rows = db.prepare('SELECT * FROM orders WHERE user_id = ? ORDER BY id DESC').all(req.user.id); + const counts = db + .prepare("SELECT order_id, COUNT(*) AS n FROM files WHERE source = 'admin' AND order_id IN (SELECT id FROM orders WHERE user_id = ?) GROUP BY order_id") + .all(req.user.id); + const msgs = db + .prepare('SELECT order_id, COUNT(*) AS n, MAX(created_at) AS last FROM messages WHERE is_admin = 1 AND order_id IN (SELECT id FROM orders WHERE user_id = ?) GROUP BY order_id') + .all(req.user.id); + const delivered = new Map(counts.map((c) => [c.order_id, c.n])); + const replies = new Map(msgs.map((m) => [m.order_id, m])); + res.json({ + orders: rows.map((o) => { + const dto = orderDTO(o); + delete dto.adminNote; + return { ...dto, deliveredFiles: delivered.get(o.id) || 0, engineerMessages: replies.get(o.id)?.n || 0, lastEngineerMessage: replies.get(o.id)?.last || null }; + }), + }); + }); + + r.post('/orders', requireUser, upload.array('files', 20), asyncH(async (req, res) => { + try { + let raw; + try { + raw = JSON.parse(req.body?.specs || '{}'); + } catch { + throw new HttpError(400, 'Invalid order specification.'); + } + const title = cleanText(req.body?.title, 160); + if (title.length < 2) throw new HttpError(400, 'Give your project a title.'); + if (!req.files?.length) throw new HttpError(400, 'Upload at least one project file (ideally the .kicad_pcb).'); + const { meta, error: metaError } = await boardMetaFromUploads(req.files); + const specs = normalizeSpecs(raw, meta); + const pricing = getSetting(db, 'pricing'); + const quote = computeQuote(specs, pricing, meta); + const auto = !!pricing.autoQuote; + const code = newOrderCode(); + const info = db + .prepare(`INSERT INTO orders (code, user_id, title, service, specs, board_meta, notes, price, currency, price_breakdown, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + code, + req.user.id, + title, + specs.service, + JSON.stringify(specs), + meta ? JSON.stringify(meta) : null, + cleanText(req.body?.notes, 5000) || null, + auto ? quote.total : null, + config.currency, + JSON.stringify(quote.lines), + auto ? 'awaiting_payment' : 'quote_pending', + ); + const orderId = Number(info.lastInsertRowid); + await storeUploads(ctx, req.files, { orderId, uploaderId: req.user.id, source: 'customer' }); + logEvent(db, orderId, req.user.id, 'created', { status: auto ? 'awaiting_payment' : 'quote_pending', price: auto ? quote.total : null }); + if (metaError) logEvent(db, orderId, null, 'parse_warning', { message: metaError }); + res.status(201).json(detail(req, db.prepare('SELECT * FROM orders WHERE id = ?').get(orderId))); + } finally { + await cleanupTemp(req.files); + } + })); + + r.get('/orders/:code', requireUser, (req, res) => { + res.json(detail(req, findOwnOrder(req))); + }); + + r.post('/orders/:code/files', requireUser, upload.array('files', 20), asyncH(async (req, res) => { + try { + const o = findOwnOrder(req); + if (['cancelled', 'completed'].includes(o.status)) throw new HttpError(409, 'This order is closed.'); + if (!req.files?.length) throw new HttpError(400, 'No file selected.'); + const source = req.user.role === 'admin' && o.user_id !== req.user.id ? 'admin' : 'customer'; + const rows = await storeUploads(ctx, req.files, { orderId: o.id, uploaderId: req.user.id, source, note: cleanText(req.body?.note, 500) }); + logEvent(db, o.id, req.user.id, 'files_added', { count: rows.length, source }); + touch(o.id); + res.status(201).json(detail(req, o)); + } finally { + await cleanupTemp(req.files); + } + })); + + r.post('/orders/:code/messages', requireUser, (req, res) => { + const o = findOwnOrder(req); + const body = cleanText(req.body?.body, 5000); + if (!body) throw new HttpError(400, 'Message is empty.'); + db.prepare('INSERT INTO messages (order_id, user_id, is_admin, body) VALUES (?, ?, ?, ?)').run(o.id, req.user.id, req.user.role === 'admin' ? 1 : 0, body); + touch(o.id); + res.status(201).json(detail(req, o)); + }); + + r.post('/orders/:code/cancel', requireUser, (req, res) => { + const o = findOwnOrder(req); + if (!['quote_pending', 'awaiting_payment'].includes(o.status)) throw new HttpError(409, 'Only unpaid orders can be cancelled.'); + db.prepare("UPDATE orders SET status = 'cancelled', updated_at = datetime('now') WHERE id = ?").run(o.id); + db.prepare("UPDATE payments SET status = 'failed', error = 'order cancelled' WHERE order_id = ? AND status = 'pending'").run(o.id); + logEvent(db, o.id, req.user.id, 'status', { from: o.status, to: 'cancelled' }); + res.json(detail(req, db.prepare('SELECT * FROM orders WHERE id = ?').get(o.id))); + }); + + r.post('/orders/:code/confirm', requireUser, (req, res) => { + const o = findOwnOrder(req); + if (o.status !== 'delivered') throw new HttpError(409, 'This order has not been delivered yet.'); + db.prepare("UPDATE orders SET status = 'completed', updated_at = datetime('now') WHERE id = ?").run(o.id); + logEvent(db, o.id, req.user.id, 'status', { from: o.status, to: 'completed' }); + res.json(detail(req, db.prepare('SELECT * FROM orders WHERE id = ?').get(o.id))); + }); + + // ── payments ── + r.post('/orders/:code/pay', requireUser, asyncH(async (req, res) => { + const o = findOwnOrder(req); + if (o.user_id !== req.user.id) throw new HttpError(403, 'Only the order owner can pay for it.'); + if (o.status !== 'awaiting_payment' || !(o.price > 0)) throw new HttpError(409, 'This order is not ready for payment.'); + res.json(await payments.start(o, req.user, req.body?.method)); + })); + + r.get('/payments', requireUser, (req, res) => { + const rows = db + .prepare(`SELECT p.*, o.code, o.title FROM payments p JOIN orders o ON o.id = p.order_id + WHERE o.user_id = ? AND p.status = 'paid' ORDER BY p.id DESC`) + .all(req.user.id); + res.json({ payments: rows.map((p) => ({ ...paymentDTO(p), orderCode: p.code, orderTitle: p.title })) }); + }); + + r.get('/payments/:pid/receipt', requireUser, (req, res) => { + const p = payments.byPublicId(req.params.pid); + const o = p && db.prepare('SELECT * FROM orders WHERE id = ?').get(p.order_id); + if (!p || !o || (o.user_id !== req.user.id && req.user.role !== 'admin') || p.status !== 'paid') throw new HttpError(404, 'Receipt not found.'); + const customer = db.prepare('SELECT name, email, company, country FROM users WHERE id = ?').get(o.user_id); + res.json({ + payment: paymentDTO(p), + order: { code: o.code, title: o.title, service: o.service, serviceLabel: SERVICES[o.service], lines: o.price_breakdown && !o.price_is_manual ? JSON.parse(o.price_breakdown) : null }, + customer, + seller: { name: config.companyName, site: config.siteName, email: config.supportEmail, url: config.baseUrl }, + }); + }); + + // Stripe / PayPal send the customer back here (browser redirect). + r.get('/payments/:provider/return', asyncH(async (req, res) => { + const payment = payments.byPublicId(req.query.pid); + if (!payment || payment.provider !== req.params.provider || payment.provider === 'mock') throw new HttpError(404, 'Payment not found.'); + const { order, ok } = await payments.finalize(payment, { token: req.query.token ? String(req.query.token) : undefined }); + res.redirect(302, `/portal/orders/${order.code}?payment=${ok ? 'success' : 'failed'}`); + })); + + // Test checkout (mock provider only) + const mockPayment = (req) => { + if (!ctx.providers.has('mock')) throw new HttpError(404, 'The test checkout is disabled.'); + const p = payments.byPublicId(req.params.pid); + const o = p && db.prepare('SELECT * FROM orders WHERE id = ?').get(p.order_id); + if (!p || p.provider !== 'mock' || !o || o.user_id !== req.user.id) throw new HttpError(404, 'Payment not found.'); + return { p, o }; + }; + r.get('/payments/mock/:pid', requireUser, (req, res) => { + const { p, o } = mockPayment(req); + res.json({ amount: p.amount, currency: p.currency, status: p.status, order: { code: o.code, title: o.title } }); + }); + r.post('/payments/mock/:pid', requireUser, asyncH(async (req, res) => { + const { p, o } = mockPayment(req); + const { ok } = await payments.finalize(p, { outcome: req.body?.outcome === 'OK' ? 'OK' : 'NOK' }); + res.json({ redirectUrl: `/portal/orders/${o.code}?payment=${ok ? 'success' : 'failed'}` }); + })); + + // ── file download (order owner or admin) ── + const findFile = (req) => { + const f = db + .prepare('SELECT f.*, o.user_id, o.code, o.specs FROM files f JOIN orders o ON o.id = f.order_id WHERE f.id = ?') + .get(String(req.params.id)); + if (!f || (f.user_id !== req.user.id && req.user.role !== 'admin')) throw new HttpError(404, 'File not found.'); + return f; + }; + + r.get('/files/:id', requireUser, (req, res, next) => { + const f = findFile(req); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Length', String(f.size)); + res.setHeader('Cache-Control', 'private, no-store'); + const ascii = f.original_name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); + res.setHeader('Content-Disposition', `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(f.original_name)}`); + const stream = ctx.storage.stream(f.stored_name); + stream.on('error', next); + stream.pipe(res); + }); + + r.get('/files/:id/meta', requireUser, (req, res) => { + const f = findFile(req); + const specs = JSON.parse(f.specs); + res.json({ + id: f.id, + name: f.original_name, + size: f.size, + sha256: f.sha256, + viewerKind: f.viewer_kind, + source: f.source, + orderCode: f.code, + look: { maskColor: specs.maskColor, finish: specs.finish, silkColor: specs.silkColor }, + }); + }); + + return r; +} diff --git a/pcb-portal/server/routes/shared.js b/pcb-portal/server/routes/shared.js new file mode 100644 index 0000000..7b65fce --- /dev/null +++ b/pcb-portal/server/routes/shared.js @@ -0,0 +1,159 @@ +import multer from 'multer'; +import { randomBytes } from 'node:crypto'; +import { promises as fsp } from 'node:fs'; +import { ALLOWED_EXTENSIONS, extensionOf, safeFileName, viewerKindOf } from '../storage.js'; +import { summarizeKiCadPcb } from '../../public/js/viewer/kicad-parser.js'; + +export const STATUS_LABELS = { + quote_pending: 'Awaiting quote', + awaiting_payment: 'Awaiting payment', + paid: 'Paid — queued', + in_progress: 'In progress', + delivered: 'Delivered', + completed: 'Completed', + cancelled: 'Cancelled', +}; + +export const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + +export class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +/** Multer instance writing to DATA_DIR/tmp with an extension allow-list. */ +export function uploader(ctx, maxFiles = 20) { + return multer({ + dest: ctx.storage.tmpDir, + limits: { fileSize: ctx.config.maxUploadMb * 1024 * 1024, files: maxFiles, fields: 50, fieldSize: 256 * 1024 }, + fileFilter(_req, file, cb) { + file.originalname = fixEncoding(file.originalname); + const ext = extensionOf(file.originalname); + if (!ALLOWED_EXTENSIONS.has(ext)) return cb(new HttpError(400, `File type not allowed: "${safeFileName(file.originalname)}".`)); + cb(null, true); + }, + }); +} + +/** busboy reports non-ASCII filenames as latin1; recover UTF-8. */ +function fixEncoding(name) { + try { + const utf8 = Buffer.from(name, 'latin1').toString('utf8'); + return utf8.includes('�') ? name : utf8; + } catch { + return name; + } +} + +/** Store uploaded files for an order, returning the inserted DB rows. */ +export async function storeUploads(ctx, files, { orderId, uploaderId, source, note }) { + const insert = ctx.db.prepare(`INSERT INTO files (id, order_id, uploader_id, source, original_name, stored_name, size, sha256, viewer_kind, note) + VALUES (@id, @order_id, @uploader_id, @source, @original_name, @stored_name, @size, @sha256, @viewer_kind, @note)`); + const rows = []; + for (const f of files || []) { + const saved = await ctx.storage.saveFromTemp(f.path, f.originalname); + const row = { + id: saved.id, + order_id: orderId, + uploader_id: uploaderId, + source, + original_name: safeFileName(f.originalname), + stored_name: saved.storedName, + size: saved.size, + sha256: saved.sha256, + viewer_kind: viewerKindOf(f.originalname), + note: note || null, + }; + insert.run(row); + rows.push(row); + } + return rows; +} + +export async function cleanupTemp(files) { + for (const f of files || []) if (f.path) await fsp.rm(f.path, { force: true }).catch(() => {}); +} + +/** Parse the first .kicad_pcb among uploads (before they are moved). */ +export async function boardMetaFromUploads(files) { + const pcb = (files || []).find((f) => extensionOf(f.originalname) === 'kicad_pcb'); + if (!pcb || pcb.size > 80 * 1024 * 1024) return { meta: null, error: null }; + try { + const text = await fsp.readFile(pcb.path, 'utf8'); + return { meta: summarizeKiCadPcb(text), error: null }; + } catch (err) { + return { meta: null, error: `${safeFileName(pcb.originalname)} could not be analysed: ${err.message}` }; + } +} + +export function newOrderCode() { + const d = new Date(); + const ym = `${String(d.getUTCFullYear()).slice(2)}${String(d.getUTCMonth() + 1).padStart(2, '0')}`; + return `QX-${ym}-${randomBytes(3).toString('hex').toUpperCase()}`; +} + +export function orderDTO(o) { + if (!o) return null; + return { + id: o.id, + code: o.code, + title: o.title, + service: o.service, + specs: JSON.parse(o.specs), + boardMeta: o.board_meta ? JSON.parse(o.board_meta) : null, + notes: o.notes, + price: o.price, + currency: o.currency, + priceBreakdown: o.price_breakdown ? JSON.parse(o.price_breakdown) : null, + priceIsManual: !!o.price_is_manual, + status: o.status, + statusLabel: STATUS_LABELS[o.status] || o.status, + adminNote: o.admin_note, + createdAt: o.created_at, + updatedAt: o.updated_at, + paidAt: o.paid_at, + ...(o.customer_email ? { customer: { name: o.customer_name, email: o.customer_email, phone: o.customer_phone, company: o.customer_company } } : {}), + }; +} + +export function fileDTO(f) { + return { + id: f.id, + name: f.original_name, + size: f.size, + sha256: f.sha256, + source: f.source, + viewerKind: f.viewer_kind, + note: f.note, + createdAt: f.created_at, + }; +} + +export function paymentDTO(p) { + return { id: p.public_id, provider: p.provider, amount: p.amount, currency: p.currency, status: p.status, refId: p.ref_id, error: p.error, createdAt: p.created_at, paidAt: p.paid_at }; +} + +/** Full order detail (files, messages, payments, events). Admin notes are stripped for customers. */ +export function orderDetail(db, order, { admin = false } = {}) { + const files = db.prepare('SELECT * FROM files WHERE order_id = ? ORDER BY created_at, rowid').all(order.id).map(fileDTO); + const messages = db + .prepare('SELECT m.id, m.body, m.is_admin, m.created_at, u.name AS author FROM messages m JOIN users u ON u.id = m.user_id WHERE m.order_id = ? ORDER BY m.id') + .all(order.id) + .map((m) => ({ id: m.id, body: m.body, isAdmin: !!m.is_admin, author: m.author, createdAt: m.created_at })); + const payments = db.prepare('SELECT * FROM payments WHERE order_id = ? ORDER BY id DESC').all(order.id).map(paymentDTO); + const events = db + .prepare('SELECT type, data, created_at FROM order_events WHERE order_id = ? ORDER BY id') + .all(order.id) + .map((e) => ({ type: e.type, data: e.data ? JSON.parse(e.data) : null, createdAt: e.created_at })); + const dto = orderDTO(order); + if (!admin) delete dto.adminNote; + return { order: dto, files, messages, payments, events }; +} + +export function cleanText(v, max) { + return String(v ?? '').replace(/\u0000/g, '').trim().slice(0, max); +} + +export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; diff --git a/pcb-portal/server/storage.js b/pcb-portal/server/storage.js new file mode 100644 index 0000000..3c4a212 --- /dev/null +++ b/pcb-portal/server/storage.js @@ -0,0 +1,67 @@ +// Private file storage for order attachments. Files live under DATA_DIR/files +// with random names and are only ever served through the authorised +// /api/files/:id route — never from a static directory. +import { createHash, randomBytes } from 'node:crypto'; +import { createReadStream, mkdirSync, promises as fsp } from 'node:fs'; +import { extname, join } from 'node:path'; + +export const ALLOWED_EXTENSIONS = new Set([ + // KiCad project + 'kicad_pcb', 'kicad_pro', 'kicad_sch', 'kicad_prl', 'kicad_dru', 'kicad_sym', 'kicad_mod', 'net', + // 3D + 'glb', 'gltf', 'stl', 'obj', 'wrl', 'step', 'stp', + // fabrication outputs + 'gbr', 'gtl', 'gbl', 'gto', 'gbo', 'gts', 'gbs', 'gtp', 'gbp', 'gko', 'gm1', 'g1', 'g2', 'g3', 'g4', 'drl', 'xln', 'ipc', 'pos', 'csv', + // archives & documents + 'zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'txt', 'md', 'json', 'xlsx', 'xls', 'ods', 'html', +]); + +export const VIEWER_KINDS = new Set(['kicad_pcb', 'glb', 'gltf', 'stl', 'obj', 'wrl']); + +export function extensionOf(name) { + const lower = String(name || '').toLowerCase(); + if (lower.endsWith('.tar.gz')) return 'gz'; + return extname(lower).slice(1); +} + +export function viewerKindOf(name) { + const ext = extensionOf(name); + return VIEWER_KINDS.has(ext) ? ext : null; +} + +/** Strip path components and control characters; keep unicode (Persian names). */ +export function safeFileName(name) { + const base = String(name || 'file').split(/[\\/]/).pop(); + const cleaned = base.replace(/[\u0000-\u001f\u007f"<>|:*?]/g, '_').replace(/^\.+/, '').trim(); + return (cleaned || 'file').slice(0, 180); +} + +export function createStorage(dataDir) { + const dir = join(dataDir, 'files'); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(dataDir, 'tmp'), { recursive: true }); + return { + dir, + tmpDir: join(dataDir, 'tmp'), + /** Move a multer temp upload into storage; returns stored metadata. */ + async saveFromTemp(tmpPath, originalName) { + const id = randomBytes(16).toString('hex'); + const ext = extensionOf(originalName); + const storedName = `${id}${ext ? '.' + ext : ''}`; + const hash = createHash('sha256'); + for await (const chunk of createReadStream(tmpPath)) hash.update(chunk); + const { size } = await fsp.stat(tmpPath); + await fsp.rename(tmpPath, join(dir, storedName)); + return { id, storedName, size, sha256: hash.digest('hex') }; + }, + path(storedName) { + return join(dir, storedName); + }, + stream(storedName) { + return createReadStream(join(dir, storedName)); + }, + async remove(storedName) { + await fsp.rm(join(dir, storedName), { force: true }); + }, + }; +} diff --git a/pcb-portal/test/api.test.js b/pcb-portal/test/api.test.js new file mode 100644 index 0000000..6f4103b --- /dev/null +++ b/pcb-portal/test/api.test.js @@ -0,0 +1,365 @@ +import { after, before, describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createApp } from '../server/app.js'; +import { readConfig } from '../server/config.js'; +import { stripeProvider } from '../server/payments/stripe.js'; +import { makeBoxGlb } from './make-glb.js'; + +const demo = readFileSync(new URL('../public/samples/demo-board.kicad_pcb', import.meta.url)); + +/** Boot an app on a random port with its own data dir. */ +async function boot(env = {}, deps = {}) { + const dataDir = mkdtempSync(join(tmpdir(), 'qodex-pcb-test-')); + const config = readConfig({ + DATA_DIR: dataDir, + ADMIN_EMAIL: 'admin@test.io', + ADMIN_PASSWORD: 'admin-password', + PUBLIC_BASE_URL: 'http://127.0.0.1:1', + ...env, + }); + const { app, close } = await createApp(config, deps); + const server = await new Promise((res) => { + const s = app.listen(0, '127.0.0.1', () => res(s)); + }); + return { + base: `http://127.0.0.1:${server.address().port}`, + async stop() { + await new Promise((r) => server.close(r)); + close(); + rmSync(dataDir, { recursive: true, force: true }); + }, + }; +} + +/** Minimal cookie-keeping client. */ +function client(base) { + let cookie = ''; + async function call(method, path, { json, form, raw, headers = {}, csrf = true } = {}) { + const h = { ...headers }; + if (cookie) h.cookie = cookie; + if (csrf) h['x-qodex-request'] = '1'; + let body; + if (json !== undefined) { + h['content-type'] = 'application/json'; + body = JSON.stringify(json); + } else if (form) body = form; + else if (raw !== undefined) body = raw; + const res = await fetch(base + path, { method, headers: h, body, redirect: 'manual' }); + const set = res.headers.get('set-cookie'); + if (set) cookie = set.split(';')[0]; + const type = res.headers.get('content-type') || ''; + const data = type.includes('json') ? await res.json() : type.includes('text') || type.includes('xml') ? await res.text() : Buffer.from(await res.arrayBuffer()); + return { status: res.status, data, headers: res.headers }; + } + return { + get: (p, o) => call('GET', p, o), + post: (p, o) => call('POST', p, o), + patch: (p, o) => call('PATCH', p, o), + put: (p, o) => call('PUT', p, o), + }; +} + +function orderForm(specs, files = [['demo.kicad_pcb', demo]], title = 'Test board') { + const f = new FormData(); + f.append('title', title); + f.append('specs', JSON.stringify(specs)); + for (const [name, buf] of files) f.append('files', new Blob([buf]), name); + return f; +} + +const register = (c, name, email) => c.post('/api/auth/register', { json: { name, email, password: `${name}-password`, acceptTerms: true } }); + +describe('portal API (mock checkout)', () => { + let app, alice, bob, admin, order; + + before(async () => { + app = await boot(); + alice = client(app.base); + bob = client(app.base); + admin = client(app.base); + assert.equal((await register(alice, 'Alice', 'alice@test.io')).status, 201); + assert.equal((await register(bob, 'Bob', 'bob@test.io')).status, 201); + assert.equal((await admin.post('/api/auth/login', { json: { email: 'admin@test.io', password: 'admin-password' } })).status, 200); + }); + after(() => app.stop()); + + test('public pages render server-side with SEO metadata', async () => { + const anon = client(app.base); + for (const path of ['/', '/how-it-works', '/studio', '/pricing', '/use-cases', '/use-cases/rf-microwave', '/integrations', '/evidence-library', '/docs', '/trust-security', '/about', '/careers', '/contact', '/legal', '/login', '/register']) { + const r = await anon.get(path); + assert.equal(r.status, 200, path); + assert.match(r.data, /[^<]+<\/title>/, path); + assert.match(r.data, /<link rel="canonical"/, path); + assert.doesNotMatch(r.data, /[\u0600-\u06FF]/, `${path} contains non-English script`); + assert.doesNotMatch(r.data, /\{\{/, `${path} has an unfilled placeholder`); + } + assert.equal((await anon.get('/use-cases/nope')).status, 404); + const sitemap = await anon.get('/sitemap.xml'); + assert.match(sitemap.data, /\/use-cases\/hdi-microvia/); + assert.match((await anon.get('/robots.txt')).data, /Disallow: \/portal/); + assert.match((await anon.get('/llms.txt')).data, /KiCad/); + }); + + test('state-changing requests without the CSRF header are rejected', async () => { + assert.equal((await alice.post('/api/orders/X/cancel', { csrf: false })).status, 403); + }); + + test('registration rules and wrong password', async () => { + const c = client(app.base); + assert.equal((await c.post('/api/auth/register', { json: { name: 'Al', email: 'ALICE@test.io', password: 'whatever12', acceptTerms: true } })).status, 409); + assert.equal((await c.post('/api/auth/register', { json: { name: 'Carol', email: 'carol@test.io', password: 'whatever12' } })).status, 400, 'terms must be accepted'); + assert.equal((await c.post('/api/auth/login', { json: { email: 'alice@test.io', password: 'nope-nope' } })).status, 401); + }); + + test('portal and admin pages require the right role', async () => { + const r = await fetch(app.base + '/portal', { redirect: 'manual' }); + assert.equal(r.status, 302); + assert.match(r.headers.get('location'), /^\/login\?next=/); + assert.equal((await alice.get('/admin')).status, 302); + assert.equal((await alice.get('/portal')).status, 200); + }); + + test('config exposes USD and the enabled checkouts', async () => { + const { data } = await alice.get('/api/config'); + assert.equal(data.currency, 'USD'); + assert.deepEqual(data.paymentMethods.map((m) => m.id), ['mock']); + }); + + test('create order: board is parsed server-side and auto-quoted in USD', async () => { + const r = await alice.post('/api/orders', { form: orderForm({ service: 'routing_fab', quantity: 10, maskColor: 'black', layers: 2, widthMm: 5, heightMm: 5, nets: 0, pads: 0 }) }); + assert.equal(r.status, 201, JSON.stringify(r.data)); + order = r.data.order; + assert.equal(order.status, 'awaiting_payment'); + assert.equal(order.currency, 'USD'); + assert.equal(order.boardMeta.copperLayerCount, 4); + assert.equal(order.specs.layers, 4, 'layers taken from the board file'); + assert.equal(order.specs.widthMm, 64); + assert.equal(order.specs.nets, 11); + assert.ok(order.price >= 4900); + assert.equal(r.data.files[0].viewerKind, 'kicad_pcb'); + assert.equal(order.adminNote, undefined, 'internal note is never sent to customers'); + }); + + test('disallowed file types are rejected', async () => { + assert.equal((await alice.post('/api/orders', { form: orderForm({ service: 'routing' }, [['evil.exe', Buffer.from('MZ')]]) })).status, 400); + }); + + test('other customers cannot see the order or its files', async () => { + const fileId = (await alice.get(`/api/orders/${order.code}`)).data.files[0].id; + assert.equal((await bob.get(`/api/orders/${order.code}`)).status, 404); + assert.equal((await bob.get(`/api/files/${fileId}`)).status, 404); + assert.equal((await client(app.base).get(`/api/files/${fileId}`)).status, 401); + const own = await alice.get(`/api/files/${fileId}`); + assert.equal(own.status, 200); + assert.ok(Buffer.compare(own.data, demo) === 0, 'download is byte-identical'); + assert.equal((await bob.post(`/api/orders/${order.code}/pay`)).status, 404); + }); + + test('unknown payment method is refused', async () => { + assert.equal((await alice.post(`/api/orders/${order.code}/pay`, { json: { method: 'bitcoin' } })).status, 400); + }); + + test('pay via the test checkout; replays are idempotent; receipt issued', async () => { + const r = await alice.post(`/api/orders/${order.code}/pay`, { json: { method: 'mock' } }); + assert.equal(r.status, 200); + const pid = r.data.redirectUrl.split('/').pop(); + assert.equal((await bob.post(`/api/payments/mock/${pid}`, { json: { outcome: 'OK' } })).status, 404); + assert.match((await alice.post(`/api/payments/mock/${pid}`, { json: { outcome: 'OK' } })).data.redirectUrl, /payment=success/); + assert.match((await alice.post(`/api/payments/mock/${pid}`, { json: { outcome: 'OK' } })).data.redirectUrl, /payment=success/); + const d = (await alice.get(`/api/orders/${order.code}`)).data; + assert.equal(d.order.status, 'paid'); + assert.equal(d.payments.filter((p) => p.status === 'paid').length, 1); + assert.equal((await alice.post(`/api/orders/${order.code}/pay`)).status, 409, 'cannot pay twice'); + + const list = (await alice.get('/api/payments')).data.payments; + assert.equal(list.length, 1); + const receipt = await alice.get(`/api/payments/${list[0].id}/receipt`); + assert.equal(receipt.status, 200); + assert.equal(receipt.data.payment.amount, order.price); + assert.equal((await bob.get(`/api/payments/${list[0].id}/receipt`)).status, 404); + }); + + test('admin cannot reprice a paid order; non-admins get 403', async () => { + assert.equal((await admin.patch(`/api/admin/orders/${order.code}`, { json: { price: 1 } })).status, 409); + assert.equal((await alice.get('/api/admin/orders')).status, 403); + }); + + test('admin delivers a GLB; the customer can list and download it', async () => { + const glb = makeBoxGlb(); + const f = new FormData(); + f.append('files', new Blob([glb]), 'routed-board.glb'); + f.append('note', 'final routed board'); + f.append('markDelivered', 'true'); + const r = await admin.post(`/api/admin/orders/${order.code}/files`, { form: f }); + assert.equal(r.status, 201, JSON.stringify(r.data)); + assert.equal(r.data.order.status, 'delivered'); + const d = (await alice.get(`/api/orders/${order.code}`)).data; + const delivered = d.files.find((x) => x.source === 'admin'); + assert.equal(delivered.viewerKind, 'glb'); + assert.ok(Buffer.compare((await alice.get(`/api/files/${delivered.id}`)).data, glb) === 0); + assert.equal((await alice.get(`/api/files/${delivered.id}/meta`)).data.look.maskColor, 'black'); + }); + + test('messages thread and completion', async () => { + await admin.post(`/api/orders/${order.code}/messages`, { json: { body: 'Routed, please review.' } }); + await alice.post(`/api/orders/${order.code}/messages`, { json: { body: 'Looks good!' } }); + const d = (await alice.get(`/api/orders/${order.code}`)).data; + assert.deepEqual(d.messages.map((m) => m.isAdmin), [true, false]); + assert.equal((await alice.post(`/api/orders/${order.code}/confirm`)).data.order.status, 'completed'); + }); + + test('manual quoting: order waits for a price, then becomes payable', async () => { + const settings = (await admin.get('/api/admin/settings/pricing')).data.pricing; + settings.autoQuote = false; + assert.equal((await admin.put('/api/admin/settings/pricing', { json: { pricing: settings } })).status, 200); + const o = (await alice.post('/api/orders', { form: orderForm({ service: 'routing' }) })).data.order; + assert.equal(o.status, 'quote_pending'); + assert.equal(o.price, null); + assert.equal((await alice.post(`/api/orders/${o.code}/pay`)).status, 409); + const p = await admin.patch(`/api/admin/orders/${o.code}`, { json: { price: 249.5, adminNote: 'tricky BGA' } }); + assert.equal(p.data.order.status, 'awaiting_payment'); + assert.equal(p.data.order.price, 24950); + assert.equal((await alice.get(`/api/orders/${o.code}`)).data.order.adminNote, undefined); + assert.equal((await alice.post(`/api/orders/${o.code}/pay`)).status, 200); + await admin.patch(`/api/admin/orders/${o.code}`, { json: { price: 300 } }); + assert.equal((await alice.get(`/api/orders/${o.code}`)).data.payments[0].status, 'failed', 'repricing cancels the open checkout'); + assert.equal((await alice.post(`/api/orders/${o.code}/cancel`)).data.order.status, 'cancelled'); + }); + + test('contact form: validation, honeypot, admin inbox', async () => { + const anon = client(app.base); + assert.equal((await anon.post('/api/contact', { json: { name: 'X', email: 'bad', message: 'hi' } })).status, 400); + assert.equal((await anon.post('/api/contact', { json: { name: 'Spam', email: 's@x.io', message: 'buy stuff now please', website: 'http://spam' } })).status, 201); + assert.equal((await anon.post('/api/contact', { json: { name: 'Dana', email: 'dana@corp.io', topic: 'sales', message: 'We need 20 boards routed per quarter.' } })).status, 201); + const { inquiries } = (await admin.get('/api/admin/inquiries')).data; + assert.equal(inquiries.length, 1, 'honeypot submission was dropped'); + assert.equal(inquiries[0].topic, 'sales'); + assert.equal((await admin.patch(`/api/admin/inquiries/${inquiries[0].id}`, { json: { status: 'handled' } })).status, 200); + }); +}); + +describe('PayPal + Stripe checkout', () => { + let app, alice, calls, orderCode; + const STRIPE_WHSEC = 'whsec_test_secret'; + let stripeSession = null; + let ppCount = 0; + + const fakeFetch = async (url, init = {}) => { + const body = init.body && typeof init.body === 'string' && init.body.startsWith('{') ? JSON.parse(init.body) : init.body; + calls.push({ url, method: init.method, body, headers: init.headers }); + // PayPal + if (url.endsWith('/v1/oauth2/token')) return Response.json({ access_token: 'tok', expires_in: 3600 }); + if (url.endsWith('/v2/checkout/orders') && init.method === 'POST') { + ppCount++; + if (ppCount > 1) return Response.json({ id: `PP-ORDER-${ppCount}`, status: 'PAYER_ACTION_REQUIRED', links: [{ rel: 'payer-action', href: `https://www.sandbox.paypal.com/checkoutnow?token=PP-ORDER-${ppCount}` }] }, { status: 201 }); + return Response.json({ id: 'PP-ORDER-1', status: 'PAYER_ACTION_REQUIRED', links: [{ rel: 'payer-action', href: 'https://www.sandbox.paypal.com/checkoutnow?token=PP-ORDER-1' }] }, { status: 201 }); + } + const cap = /\/v2\/checkout\/orders\/(PP-ORDER-\d+)\/capture$/.exec(url); + if (cap) { + const id = cap[1]; + const created = calls.filter((c) => c.url.endsWith('/v2/checkout/orders'))[Number(id.split('-').pop()) - 1]; + const unit = created.body.purchase_units[0]; + return Response.json({ id, status: 'COMPLETED', purchase_units: [{ custom_id: unit.custom_id, payments: { captures: [{ id: 'CAPTURE-9', status: 'COMPLETED', amount: unit.amount }] } }] }, { status: 201 }); + } + // Stripe + if (url.endsWith('/v1/checkout/sessions') && init.method === 'POST') { + const p = new URLSearchParams(init.body); + stripeSession = { id: 'cs_test_1', url: 'https://checkout.stripe.com/c/pay/cs_test_1', client_reference_id: p.get('client_reference_id'), amount_total: Number(p.get('line_items[0][price_data][unit_amount]')), currency: p.get('line_items[0][price_data][currency]'), payment_status: 'unpaid', payment_intent: 'pi_1' }; + return Response.json(stripeSession); + } + if (url.includes('/v1/checkout/sessions/cs_test_1')) return Response.json(stripeSession); + return new Response('not found', { status: 404 }); + }; + + before(async () => { + calls = []; + app = await boot({ + PAYMENT_PROVIDERS: 'stripe,paypal', + STRIPE_SECRET_KEY: 'sk_test_x', + STRIPE_WEBHOOK_SECRET: STRIPE_WHSEC, + PAYPAL_CLIENT_ID: 'id', + PAYPAL_CLIENT_SECRET: 'secret', + PAYPAL_SANDBOX: 'true', + }, { fetch: fakeFetch }); + alice = client(app.base); + await register(alice, 'Alice', 'alice@test.io'); + }); + after(() => app.stop()); + + test('PayPal: create order → approve redirect → capture on return', async () => { + const o = (await alice.post('/api/orders', { form: orderForm({ service: 'routing' }) })).data.order; + const pay = await alice.post(`/api/orders/${o.code}/pay`, { json: { method: 'paypal' } }); + assert.equal(pay.data.redirectUrl, 'https://www.sandbox.paypal.com/checkoutnow?token=PP-ORDER-1'); + const create = calls.find((c) => c.url === 'https://api-m.sandbox.paypal.com/v2/checkout/orders'); + assert.equal(create.body.purchase_units[0].amount.currency_code, 'USD'); + assert.equal(create.body.purchase_units[0].amount.value, (o.price / 100).toFixed(2)); + const returnUrl = new URL(create.body.payment_source.paypal.experience_context.return_url); + const pid = returnUrl.searchParams.get('pid'); + + const forged = await alice.get(`/api/payments/paypal/return?pid=${pid}&token=SOMEONE-ELSE`); + assert.match(forged.headers.get('location'), /payment=failed/); + assert.equal((await alice.get(`/api/orders/${o.code}`)).data.order.status, 'awaiting_payment', 'forged token must not mark the order paid'); + }); + + test('PayPal: a genuine return captures and marks the order paid', async () => { + const o = (await alice.post('/api/orders', { form: orderForm({ service: 'routing' }) })).data.order; + orderCode = o.code; + await alice.post(`/api/orders/${o.code}/pay`, { json: { method: 'paypal' } }); + const create = calls.filter((c) => c.url === 'https://api-m.sandbox.paypal.com/v2/checkout/orders').at(-1); + const pid = new URL(create.body.payment_source.paypal.experience_context.return_url).searchParams.get('pid'); + const ret = await alice.get(`/api/payments/paypal/return?pid=${pid}&token=PP-ORDER-${ppCount}&PayerID=X`); + assert.equal(ret.headers.get('location'), `/portal/orders/${o.code}?payment=success`); + const d = (await alice.get(`/api/orders/${o.code}`)).data; + assert.equal(d.order.status, 'paid'); + assert.equal(d.payments[0].refId, 'CAPTURE-9'); + }); + + test('Stripe: unpaid session is not accepted; signed webhook finalises the payment', async () => { + const o = (await alice.post('/api/orders', { form: orderForm({ service: 'dfm_review' }) })).data.order; + const pay = await alice.post(`/api/orders/${o.code}/pay`, { json: { method: 'stripe' } }); + assert.equal(pay.data.redirectUrl, 'https://checkout.stripe.com/c/pay/cs_test_1'); + assert.equal(stripeSession.amount_total, o.price); + assert.equal(stripeSession.currency, 'usd'); + const pid = stripeSession.client_reference_id; + + // webhook with a bad signature is refused + const event = JSON.stringify({ type: 'checkout.session.completed', data: { object: { id: 'cs_test_1', client_reference_id: pid } } }); + const bad = await client(app.base).post('/api/payments/stripe/webhook', { raw: event, csrf: false, headers: { 'content-type': 'application/json', 'stripe-signature': `t=${Math.floor(Date.now() / 1000)},v1=deadbeef` } }); + assert.equal(bad.status, 400); + + // the customer comes back before Stripe reports the session as paid + stripeSession.payment_status = 'unpaid'; + // (not calling return here: it would mark the attempt failed) + + stripeSession.payment_status = 'paid'; + const t = Math.floor(Date.now() / 1000); + const sig = createHmac('sha256', STRIPE_WHSEC).update(`${t}.${event}`).digest('hex'); + const ok = await client(app.base).post('/api/payments/stripe/webhook', { raw: event, csrf: false, headers: { 'content-type': 'application/json', 'stripe-signature': `t=${t},v1=${sig}` } }); + assert.equal(ok.status, 200); + assert.equal((await alice.get(`/api/orders/${o.code}`)).data.order.status, 'paid'); + + // the browser return afterwards is a harmless no-op + const ret = await alice.get(`/api/payments/stripe/return?pid=${pid}`); + assert.match(ret.headers.get('location'), /payment=success/); + assert.ok(orderCode); + }); +}); + +test('Stripe verify rejects amount / reference mismatches', async () => { + const fake = async () => Response.json({ payment_status: 'paid', amount_total: 999, currency: 'usd', client_reference_id: 'pid1', payment_intent: 'pi_1' }); + const s = stripeProvider({ secretKey: 'sk_test' }, fake); + assert.equal((await s.verify({ authority: 'cs_1', amount: 999, currency: 'USD', paymentPublicId: 'pid1' })).ok, true); + assert.equal((await s.verify({ authority: 'cs_1', amount: 1000, currency: 'USD', paymentPublicId: 'pid1' })).ok, false); + assert.equal((await s.verify({ authority: 'cs_1', amount: 999, currency: 'USD', paymentPublicId: 'other' })).ok, false); +}); + +test('config validation', () => { + assert.throws(() => readConfig({ PAYMENT_PROVIDERS: 'zarinpal' }), /Unknown payment provider/); + assert.throws(() => readConfig({ PAYMENT_PROVIDERS: 'stripe' }), /STRIPE_SECRET_KEY/); + assert.throws(() => readConfig({ PAYMENT_PROVIDERS: 'paypal', PAYPAL_CLIENT_ID: 'x' }), /PAYPAL_CLIENT_SECRET/); + assert.equal(readConfig({}).currency, 'USD'); +}); diff --git a/pcb-portal/test/kicad-parser.test.js b/pcb-portal/test/kicad-parser.test.js new file mode 100644 index 0000000..aafcdf9 --- /dev/null +++ b/pcb-portal/test/kicad-parser.test.js @@ -0,0 +1,103 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { chainLoops, parseKiCadPcb, parseSExpr, summarizeKiCadPcb } from '../public/js/viewer/kicad-parser.js'; + +const demo = readFileSync(new URL('../public/samples/demo-board.kicad_pcb', import.meta.url), 'utf8'); + +test('parseSExpr handles strings, escapes and nesting', () => { + const t = parseSExpr('(a "b c" (d 1.5 "e\\"f") g)'); + assert.deepEqual(t, ['a', 'b c', ['d', '1.5', 'e"f'], 'g']); +}); + +test('parseSExpr reports the line of an unbalanced document', () => { + assert.throws(() => parseSExpr('(kicad_pcb\n (version 1)\n (layers'), /Unbalanced parentheses.*line/); +}); + +test('demo board: layers, outline, counts', () => { + const b = parseKiCadPcb(demo); + assert.deepEqual(b.copperLayers, ['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu']); + assert.equal(b.stats.widthMm, 64); + assert.equal(b.stats.heightMm, 42); + assert.equal(b.outline.holes.length, 1, 'slot cut-out becomes an inner loop'); + // 64x42 minus 4 rounded corners (r=3) minus the 8x4 slot + assert.ok(Math.abs(b.stats.areaCm2 - 26.48) < 0.05, `area ${b.stats.areaCm2}`); + assert.equal(b.stats.footprints, 15); + assert.equal(b.stats.nets, 11); + assert.equal(b.stats.kicadVersion, '8.x'); + assert.equal(b.zones.length, 2); + assert.ok(b.tracks.some((t) => t.pts.length > 2), 'track arcs are tessellated'); +}); + +test('footprint rotation is applied to pad positions', () => { + const b = parseKiCadPcb(demo); + // C1 at (122, 92) rotated 90°: local pad 1 at (-0.8, 0) → (122, 92.8) in KiCad Y-down space + const pad = b.pads.find((p) => p.footprint === 'C1' && Math.abs(p.x - 122) < 1e-6); + assert.ok(pad, 'C1 pad found on the rotated axis'); + assert.ok([92.8, 91.2].some((y) => Math.abs(pad.y - y) < 1e-6)); + const pads = b.pads.filter((p) => p.footprint === 'C1').map((p) => +p.y.toFixed(3)).sort(); + assert.deepEqual(pads, [91.2, 92.8]); +}); + +test('bottom-side footprints and through-hole pads', () => { + const b = parseKiCadPcb(demo); + assert.equal(b.footprints.find((f) => f.ref === 'D1').side, 'B'); + const tht = b.pads.filter((p) => p.type === 'thru_hole'); + assert.equal(tht.length, 8); + assert.ok(tht.every((p) => p.layers.includes('In1.Cu') && p.drill.w === 1), '*.Cu expands to every copper layer'); + assert.equal(b.pads.filter((p) => p.type === 'np_thru_hole').length, 4); +}); + +test('KiCad 5 legacy (module / gr_arc with angle) parses', () => { + const legacy = `(kicad_pcb (version 20171130) (host pcbnew 5.1.9) + (general (thickness 1.2)) + (layers (0 F.Cu signal) (31 B.Cu signal) (44 Edge.Cuts user)) + (net 0 "") (net 1 GND) + (gr_line (start 0 0) (end 20 0) (layer Edge.Cuts) (width 0.1)) + (gr_line (start 20 0) (end 20 10) (layer Edge.Cuts) (width 0.1)) + (gr_line (start 20 10) (end 0 10) (layer Edge.Cuts) (width 0.1)) + (gr_line (start 0 10) (end 0 0) (layer Edge.Cuts) (width 0.1)) + (module R_0603 (layer F.Cu) (at 10 5 90) + (fp_text reference R1 (at 0 0) (layer F.SilkS)) + (pad 1 smd rect (at -0.8 0 90) (size 0.9 0.9) (layers F.Cu F.Paste F.Mask) (net 1 GND)) + (pad 2 smd rect (at 0.8 0 90) (size 0.9 0.9) (layers F.Cu F.Paste F.Mask))) + (segment (start 1 1) (end 5 1) (width 0.25) (layer F.Cu) (net 1)) + )`; + const s = summarizeKiCadPcb(legacy); + assert.equal(s.copperLayerCount, 2); + assert.equal(s.widthMm, 20); + assert.equal(s.heightMm, 10); + assert.equal(s.footprints, 1); + assert.equal(s.pads, 2); + assert.equal(s.thickness, 1.2); + assert.equal(s.kicadVersion, '5.x'); +}); + +test('board without Edge.Cuts falls back to a padded bounding box', () => { + const s = summarizeKiCadPcb('(kicad_pcb (version 20240108) (segment (start 0 0) (end 10 0) (width 0.2) (layer "F.Cu")))'); + assert.equal(s.widthMm, 14); +}); + +test('chainLoops joins reversed segments', () => { + const loops = chainLoops([ + { closed: false, pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }] }, + { closed: false, pts: [{ x: 1, y: 1 }, { x: 1, y: 0 }] }, + { closed: false, pts: [{ x: 1, y: 1 }, { x: 0, y: 1 }] }, + { closed: false, pts: [{ x: 0, y: 1 }, { x: 0, y: 0 }] }, + ]); + assert.equal(loops.length, 1); + assert.equal(loops[0].length, 4); +}); + +test('rejects non-PCB documents', () => { + assert.throws(() => parseKiCadPcb('(kicad_sch (version 1))'), /Not a KiCad PCB/); +}); + +test('copper items carry resolved net names', () => { + const b = parseKiCadPcb(demo); + assert.ok(b.tracks.every((t) => typeof t.net === 'string' && t.net.length > 0)); + assert.ok(b.tracks.some((t) => t.net === 'USB_D+')); + assert.ok(b.vias.filter((v) => v.net === 'GND').length >= 18); + assert.ok(b.zones.every((z) => z.net === 'GND')); + assert.equal(b.pads.find((p) => p.footprint === 'U1' && p.number === '33').net, 'GND'); +}); diff --git a/pcb-portal/test/make-glb.js b/pcb-portal/test/make-glb.js new file mode 100644 index 0000000..3199882 --- /dev/null +++ b/pcb-portal/test/make-glb.js @@ -0,0 +1,45 @@ +// Builds a minimal valid binary glTF (a coloured box) — used by tests. +export function makeBoxGlb(sx = 20, sy = 1.6, sz = 15) { + const x = sx / 2, y = sy / 2, z = sz / 2; + const p = [ + [-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z], + [-x, -y, -z], [x, -y, -z], [x, y, -z], [-x, y, -z], + ]; + const idx = [0, 1, 2, 0, 2, 3, 1, 5, 6, 1, 6, 2, 5, 4, 7, 5, 7, 6, 4, 0, 3, 4, 3, 7, 3, 2, 6, 3, 6, 7, 4, 5, 1, 4, 1, 0]; + const pos = new Float32Array(p.flat()); + const ind = new Uint16Array(idx); + const bin = Buffer.alloc(pos.byteLength + ind.byteLength + ((4 - (ind.byteLength % 4)) % 4)); + Buffer.from(pos.buffer).copy(bin, 0); + Buffer.from(ind.buffer).copy(bin, pos.byteLength); + const json = { + asset: { version: '2.0', generator: 'qodex-test' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }], + materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.1, 0.45, 0.25, 1], metallicFactor: 0.1, roughnessFactor: 0.6 } }], + buffers: [{ byteLength: bin.length }], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: pos.byteLength, target: 34962 }, + { buffer: 0, byteOffset: pos.byteLength, byteLength: ind.byteLength, target: 34963 }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 8, type: 'VEC3', min: [-x, -y, -z], max: [x, y, z] }, + { bufferView: 1, componentType: 5123, count: idx.length, type: 'SCALAR' }, + ], + }; + let jsonBuf = Buffer.from(JSON.stringify(json)); + jsonBuf = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]); + const header = Buffer.alloc(12); + const total = 12 + 8 + jsonBuf.length + 8 + bin.length; + header.writeUInt32LE(0x46546c67, 0); + header.writeUInt32LE(2, 4); + header.writeUInt32LE(total, 8); + const chunk = (buf, type) => { + const h = Buffer.alloc(8); + h.writeUInt32LE(buf.length, 0); + h.writeUInt32LE(type, 4); + return Buffer.concat([h, buf]); + }; + return Buffer.concat([header, chunk(jsonBuf, 0x4e4f534a), chunk(bin, 0x004e4942)]); +} diff --git a/pcb-portal/test/pricing.test.js b/pcb-portal/test/pricing.test.js new file mode 100644 index 0000000..6bad785 --- /dev/null +++ b/pcb-portal/test/pricing.test.js @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { computeQuote, defaultPricing, normalizeSpecs, validatePricing } from '../server/pricing.js'; + +const base = { service: 'routing', layers: 4, widthMm: 50, heightMm: 40, nets: 100, pads: 400 }; + +test('normalizeSpecs fills defaults and rounds odd layer counts up', () => { + const s = normalizeSpecs({ ...base, layers: 3 }); + assert.equal(s.layers, 4); + assert.equal(s.quantity, 5); + assert.equal(s.finish, 'enig'); + assert.equal(s.impedanceControl, false); +}); + +test('normalizeSpecs rejects bad input', () => { + assert.throws(() => normalizeSpecs({ ...base, service: 'nope' }), /service/i); + assert.throws(() => normalizeSpecs({ ...base, widthMm: 1 }), /dimensions/); + assert.throws(() => normalizeSpecs({ ...base, layers: 40 }), /Layer count/); +}); + +test('server-parsed board metadata overrides client-supplied geometry', () => { + const meta = { copperLayerCount: 6, widthMm: 100, heightMm: 80, nets: 300, pads: 1200 }; + const s = normalizeSpecs({ service: 'routing', layers: 2, widthMm: 5, heightMm: 5, nets: 0, pads: 0 }, meta); + assert.deepEqual([s.layers, s.widthMm, s.heightMm, s.nets, s.pads], [6, 100, 80, 300, 1200]); + assert.equal(normalizeSpecs({ service: 'routing', layers: 8 }, meta).layers, 8, 'customer may ask for more layers'); +}); + +test('routing quote in integer US cents', () => { + const q = computeQuote(normalizeSpecs(base), defaultPricing()); + // 120 + 4*40 + 20cm²*0.6 + 100*0.15 + 400*0.02 = 120+160+12+15+8 = 315 + assert.equal(q.total, 31500); + assert.equal(q.currency, 'USD'); +}); + +test('multipliers: impedance, BGA and express stack', () => { + const p = defaultPricing(); + const plain = computeQuote(normalizeSpecs(base), p).total; + const hard = computeQuote(normalizeSpecs({ ...base, impedanceControl: true, turnaround: 'express' }), p, { hasBGA: true }).total; + assert.equal(hard, Math.ceil((plain / 100) * 1.2 * 1.25 * 1.5) * 100); +}); + +test('fabrication is included only for fab services and scales with quantity', () => { + const p = defaultPricing(); + const r = computeQuote(normalizeSpecs(base), p).total; + const rf5 = computeQuote(normalizeSpecs({ ...base, service: 'routing_fab', quantity: 5 }), p).total; + const rf50 = computeQuote(normalizeSpecs({ ...base, service: 'routing_fab', quantity: 50 }), p).total; + assert.ok(rf5 > r && rf50 > rf5); +}); + +test('minimum order amount applies', () => { + const p = defaultPricing(); + p.minimum = 200; + const q = computeQuote(normalizeSpecs({ service: 'dfm_review', layers: 1, widthMm: 5, heightMm: 5 }), p); + assert.equal(q.total, 20000); + assert.ok(q.lines.some((l) => /Minimum/.test(l.label))); +}); + +test('validatePricing accepts defaults and rejects negatives', () => { + assert.ok(validatePricing(defaultPricing())); + const bad = defaultPricing(); + bad.services.routing.base = -1; + assert.throws(() => validatePricing(bad)); +}); diff --git a/pcb-portal/views/admin/customers.html b/pcb-portal/views/admin/customers.html new file mode 100644 index 0000000..144bd9e --- /dev/null +++ b/pcb-portal/views/admin/customers.html @@ -0,0 +1,3 @@ +<!--meta {"title": "Customers | QodeX admin", "shell": "app", "script": "admin-customers", "nav": "admin-customers"} --> +<div class="page-head"><div><h1>Customers</h1><p>Accounts, order counts and lifetime spend.</p></div></div> +<div class="card table-wrap" id="list"><p class="muted">Loading…</p></div> diff --git a/pcb-portal/views/admin/inquiries.html b/pcb-portal/views/admin/inquiries.html new file mode 100644 index 0000000..0ad599c --- /dev/null +++ b/pcb-portal/views/admin/inquiries.html @@ -0,0 +1,3 @@ +<!--meta {"title": "Inquiries | QodeX admin", "shell": "app", "script": "admin-inquiries", "nav": "admin-inquiries"} --> +<div class="page-head"><div><h1>Inquiries</h1><p>Messages sent through the contact form.</p></div></div> +<div id="list" class="stack-cards"><p class="muted">Loading…</p></div> diff --git a/pcb-portal/views/admin/order.html b/pcb-portal/views/admin/order.html new file mode 100644 index 0000000..d3ed7de --- /dev/null +++ b/pcb-portal/views/admin/order.html @@ -0,0 +1,2 @@ +<!--meta {"title": "Order | QodeX admin", "shell": "app", "script": "admin-order", "nav": "admin"} --> +<div id="app"><p class="muted">Loading…</p></div> diff --git a/pcb-portal/views/admin/orders.html b/pcb-portal/views/admin/orders.html new file mode 100644 index 0000000..053f460 --- /dev/null +++ b/pcb-portal/views/admin/orders.html @@ -0,0 +1,10 @@ +<!--meta {"title": "Order queue | QodeX admin", "shell": "app", "script": "admin-orders", "nav": "admin"} --> +<div class="page-head"><div><h1>Order queue</h1><p>Everything customers have ordered, newest first.</p></div></div> +<div class="grid grid--4" id="stats"></div> +<div class="card" style="margin-top:18px"> + <div class="row" style="margin-bottom:14px"> + <select id="status" style="max-width:230px"></select> + <input type="search" id="q" placeholder="Search code, title, customer, email" style="max-width:340px" /> + </div> + <div id="orders" class="table-wrap"></div> +</div> diff --git a/pcb-portal/views/admin/settings.html b/pcb-portal/views/admin/settings.html new file mode 100644 index 0000000..ff245fa --- /dev/null +++ b/pcb-portal/views/admin/settings.html @@ -0,0 +1,12 @@ +<!--meta {"title": "Pricing | QodeX admin", "shell": "app", "script": "admin-settings", "nav": "admin-settings"} --> +<div class="page-head"><div><h1>Pricing</h1><p>Tariffs used for automatic quotes and the public estimator. All values in USD.</p></div></div> +<div class="split"> + <div class="card stack" id="form"></div> + <aside class="card"> + <h3>How a quote is built</h3> + <p class="muted">Design fee = base + per-layer × layers + per-cm² × area + per-net × nets + per-pad × pads. Controlled impedance and BGA multipliers apply to the design fee.</p> + <p class="muted">Fabrication (fab services only) = (setup + per-cm² × area × quantity × layer factor) × copper factor × (1 + finish extra + colour extra).</p> + <p class="muted">Express multiplies the subtotal. The result is rounded up and raised to the minimum order.</p> + <p class="muted">With automatic quoting off, new orders wait in "Awaiting quote" until you set a price.</p> + </aside> +</div> diff --git a/pcb-portal/views/portal/billing.html b/pcb-portal/views/portal/billing.html new file mode 100644 index 0000000..96fb0e2 --- /dev/null +++ b/pcb-portal/views/portal/billing.html @@ -0,0 +1,4 @@ +<!--meta {"title": "Billing & receipts | QodeX PCB", "shell": "app", "script": "billing", "nav": "billing"} --> +<div class="page-head"><div><h1>Billing & receipts</h1><p>Every confirmed payment, with a printable receipt. All amounts in USD.</p></div></div> +<div class="grid grid--3" id="stats"></div> +<div class="card" style="margin-top:18px"><div class="card__title"><h2>Payments</h2></div><div id="list"><p class="muted">Loading…</p></div></div> diff --git a/pcb-portal/views/portal/dashboard.html b/pcb-portal/views/portal/dashboard.html new file mode 100644 index 0000000..164b8cd --- /dev/null +++ b/pcb-portal/views/portal/dashboard.html @@ -0,0 +1,10 @@ +<!--meta {"title": "Dashboard | QodeX PCB", "shell": "app", "script": "dashboard", "nav": "portal"} --> +<div class="page-head"> + <div><h1 id="hello">Dashboard</h1><p>Your boards, their status and everything your engineer has sent back.</p></div> + <a class="btn btn--primary" href="/portal/new">+ New order</a> +</div> +<div class="grid grid--4" id="stats"></div> +<div class="card" style="margin-top:18px"> + <div class="card__title"><h2>Orders</h2></div> + <div id="orders"><p class="muted">Loading…</p></div> +</div> diff --git a/pcb-portal/views/portal/file-viewer.html b/pcb-portal/views/portal/file-viewer.html new file mode 100644 index 0000000..4c28f43 --- /dev/null +++ b/pcb-portal/views/portal/file-viewer.html @@ -0,0 +1,2 @@ +<!--meta {"title": "3D view | QodeX PCB", "shell": "bare", "script": "file-viewer", "nav": "portal"} --> +<div class="viewer-page" id="viewer"></div> diff --git a/pcb-portal/views/portal/new-order.html b/pcb-portal/views/portal/new-order.html new file mode 100644 index 0000000..f7ac296 --- /dev/null +++ b/pcb-portal/views/portal/new-order.html @@ -0,0 +1,63 @@ +<!--meta {"title": "New order | QodeX PCB", "shell": "app", "script": "new-order", "nav": "new"} --> +<div class="page-head"><div><h1>New order</h1><p>Drop your board — it's analysed and previewed in your browser before anything is uploaded.</p></div></div> +<form id="order-form" class="split" novalidate> + <div class="stack-cards"> + <section class="card"> + <div class="card__title"><h2>1 · Project files</h2><span class="low">Press <kbd>U</kbd> to browse</span></div> + <div class="dropzone" id="drop" tabindex="0" role="button" aria-label="Upload project files"> + <div class="dropzone__icon">PCB</div> + <p><strong>Drop your <code>.kicad_pcb</code> here</strong> or click to browse</p> + <p class="low">Add the zipped project, schematics, Gerbers or a PDF with notes too. Up to <span id="max-mb"></span> MB per file.</p> + <input type="file" id="files" multiple hidden /> + </div> + <ul class="file-list" id="file-list"></ul> + <div class="form-warn" id="parse-error" hidden style="margin-top:12px"></div> + </section> + <section class="card" id="preview-card" hidden> + <div class="card__title"><h2>3D preview</h2><span class="low">Rendered locally · try Inspect</span></div> + <div class="viewer-box" id="preview"></div> + </section> + <section class="card"> + <div class="card__title"><h2>2 · Specification</h2></div> + <div class="stack"> + <label class="field"><span class="field__label">Project title</span><input type="text" name="title" required maxlength="160" placeholder="e.g. Motor controller rev B" /></label> + <label class="field"><span class="field__label">Service</span><select name="service" id="service"></select><span class="field__hint" id="service-hint"></span></label> + <div class="grid grid--3"> + <label class="field"><span class="field__label">Copper layers</span><select name="layers" id="layers"></select></label> + <label class="field"><span class="field__label">Width (mm)</span><input type="number" name="widthMm" step="0.01" min="3" max="1000" required /></label> + <label class="field"><span class="field__label">Height (mm)</span><input type="number" name="heightMm" step="0.01" min="3" max="1000" required /></label> + </div> + <div class="grid grid--3" id="fab-fields"> + <label class="field"><span class="field__label">Quantity</span><input type="number" name="quantity" value="10" min="1" max="100000" /></label> + <label class="field"><span class="field__label">Board thickness</span><select name="thickness" id="thickness"></select></label> + <label class="field"><span class="field__label">Copper weight</span><select name="copperOz" id="copperOz"></select></label> + <label class="field"><span class="field__label">Surface finish</span><select name="finish" id="finish"></select></label> + <label class="field"><span class="field__label">Solder mask</span><select name="maskColor" id="maskColor"></select></label> + <label class="field"><span class="field__label">Silkscreen</span><select name="silkColor" id="silkColor"></select></label> + </div> + <div class="grid grid--2"> + <label class="field"><span class="field__label">Target fab (DFM rules)</span><select name="fabHouse" id="fabHouse"></select></label> + <label class="field"><span class="field__label">Turnaround</span><select name="turnaround" id="turnaround"></select></label> + </div> + <label class="check"><input type="checkbox" name="impedanceControl" /> <span>Controlled impedance (differential pairs, RF feeds)</span></label> + <label class="field"><span class="field__label">Engineering notes</span><textarea name="notes" maxlength="5000" placeholder="Critical nets, impedance targets, currents, length windows, locked regions…"></textarea></label> + </div> + </section> + </div> + <aside class="stack-cards" style="position:sticky;top:84px"> + <section class="card"> + <span class="eyebrow">Quote</span> + <div class="price-big" id="price">—</div> + <ul class="price-lines" id="price-lines"></ul> + <p class="low" id="quote-note" style="margin:12px 0 0"></p> + </section> + <section class="card" id="meta-card" hidden> + <div class="card__title"><h3>Board analysis</h3><span class="badge badge--completed">Parsed</span></div> + <dl class="kv" id="meta"></dl> + </section> + <div class="form-error" id="error" hidden></div> + <div id="progress" class="low" hidden></div> + <button class="btn btn--primary btn--lg btn--block" type="submit" id="submit">Place order & continue to payment</button> + <p class="low">By placing the order you agree to the <a href="/legal#terms" target="_blank">terms</a>. Your files are never used to train models.</p> + </aside> +</form> diff --git a/pcb-portal/views/portal/order.html b/pcb-portal/views/portal/order.html new file mode 100644 index 0000000..585f02e --- /dev/null +++ b/pcb-portal/views/portal/order.html @@ -0,0 +1,2 @@ +<!--meta {"title": "Order | QodeX PCB", "shell": "app", "script": "order", "nav": "portal"} --> +<div id="app"><p class="muted">Loading…</p></div> diff --git a/pcb-portal/views/portal/pay-mock.html b/pcb-portal/views/portal/pay-mock.html new file mode 100644 index 0000000..5fbe820 --- /dev/null +++ b/pcb-portal/views/portal/pay-mock.html @@ -0,0 +1,8 @@ +<!--meta {"title": "Test checkout | QodeX PCB", "shell": "app", "script": "pay-mock", "nav": "billing"} --> +<div class="card" style="max-width:520px"> + <span class="eyebrow">Development only</span> + <h1 style="font-size:26px">Test checkout</h1> + <p class="form-warn">This test gateway never charges real money. For production, enable <code>stripe</code> and/or <code>paypal</code> in <code>PAYMENT_PROVIDERS</code>.</p> + <dl class="kv" id="info" style="margin-top:16px"></dl> + <div class="row" style="margin-top:20px"><button class="btn btn--primary" id="ok" type="button">Simulate successful payment</button><button class="btn btn--danger" id="nok" type="button">Cancel</button></div> +</div> diff --git a/pcb-portal/views/portal/receipt.html b/pcb-portal/views/portal/receipt.html new file mode 100644 index 0000000..b3d27a9 --- /dev/null +++ b/pcb-portal/views/portal/receipt.html @@ -0,0 +1,3 @@ +<!--meta {"title": "Receipt | QodeX PCB", "shell": "app", "script": "receipt", "nav": "billing"} --> +<div class="row no-print" style="margin-bottom:18px"><a class="btn btn--sm" href="/portal/billing">← Billing</a><span class="spacer"></span><button class="btn btn--primary btn--sm" type="button" id="print">Print / save as PDF</button></div> +<div class="receipt" id="receipt"><p>Loading…</p></div> diff --git a/pcb-portal/views/portal/settings.html b/pcb-portal/views/portal/settings.html new file mode 100644 index 0000000..54ceea1 --- /dev/null +++ b/pcb-portal/views/portal/settings.html @@ -0,0 +1,21 @@ +<!--meta {"title": "Account settings | QodeX PCB", "shell": "app", "script": "settings", "nav": "settings"} --> +<div class="page-head"><div><h1>Account settings</h1><p>Profile details appear on your receipts.</p></div></div> +<div class="grid grid--2"> + <form class="card stack" id="profile" novalidate> + <div class="card__title"><h2>Profile</h2></div> + <label class="field"><span class="field__label">Email</span><input type="email" name="email" disabled /></label> + <label class="field"><span class="field__label">Full name</span><input type="text" name="name" required /></label> + <div class="grid grid--2"> + <label class="field"><span class="field__label">Company</span><input type="text" name="company" /></label> + <label class="field"><span class="field__label">Country</span><input type="text" name="country" /></label> + </div> + <label class="field"><span class="field__label">Phone</span><input type="tel" name="phone" /></label> + <div class="row"><button class="btn btn--primary" type="submit">Save profile</button></div> + </form> + <form class="card stack" id="password" novalidate> + <div class="card__title"><h2>Password</h2></div> + <label class="field"><span class="field__label">Current password</span><input type="password" name="currentPassword" autocomplete="current-password" required /></label> + <label class="field"><span class="field__label">New password</span><input type="password" name="newPassword" autocomplete="new-password" minlength="8" required /><span class="field__hint">At least 8 characters. Other sessions will be signed out.</span></label> + <div class="row"><button class="btn btn--primary" type="submit">Change password</button></div> + </form> +</div> diff --git a/pcb-portal/views/site/404.html b/pcb-portal/views/site/404.html new file mode 100644 index 0000000..e6a3d43 --- /dev/null +++ b/pcb-portal/views/site/404.html @@ -0,0 +1,9 @@ +<!--meta {"title": "Page not found | QodeX PCB", "robots": "noindex", "nav": ""} --> +<section class="page-hero"> + <div class="container container--narrow center"> + <span class="eyebrow">Error 404 · open net</span> + <h1>This trace doesn't connect to anything.</h1> + <p class="lead" style="margin:0 auto 28px">The page you were looking for has moved or never existed.</p> + <div class="row" style="justify-content:center"><a class="btn btn--primary" href="/">Back to home</a><a class="btn" href="/docs">Browse the docs</a></div> + </div> +</section> diff --git a/pcb-portal/views/site/about.html b/pcb-portal/views/site/about.html new file mode 100644 index 0000000..6601824 --- /dev/null +++ b/pcb-portal/views/site/about.html @@ -0,0 +1,32 @@ +<!--meta { + "title": "About — the engineering team behind QodeX PCB", + "description": "QodeX builds autonomous routing and DFM tooling for KiCad boards. Our manifesto: placement is intent, copper is physics, evidence beats trust.", + "nav": "about" +} --> +<section class="page-hero"> + <div class="container container--narrow"> + <span class="eyebrow">About</span> + <h1>We're not a vector-drawing tool. We compile copper.</h1> + <p class="lead">For decades the tools of the trade were designed before DDR5, PCIe Gen 6 and 0.4 mm BGAs. Engineers spend weeks nudging copper by hand, and the autorouters that promised to help became a byword for boards nobody can build. QodeX exists to change that.</p> + </div> +</section> +<section class="section"> + <div class="container grid grid--3"> + <div class="card feature"><span class="feature__num">PRINCIPLE 01</span><h3>Placement is intent</h3><p>Where you put a part is a design decision. We route around it — we never quietly move it.</p></div> + <div class="card feature"><span class="feature__num">PRINCIPLE 02</span><h3>Copper is physics</h3><p>Impedance, return paths and current density aren't preferences. They are constraints the router has to satisfy.</p></div> + <div class="card feature"><span class="feature__num">PRINCIPLE 03</span><h3>Evidence beats trust</h3><p>Every delivery states which rules were checked and what the result was. If we couldn't prove it, we say so.</p></div> + </div> +</section> +<section class="section"> + <div class="container split split--even"> + <div> + <h2>Why KiCad</h2> + <p class="muted">KiCad is open, text-based and version-control friendly, and it has become the tool of choice for a new generation of hardware teams. An open file format means we can promise a truly lossless round-trip — and that you are never locked into us.</p> + </div> + <div> + <h2>How we work</h2> + <p class="muted">Every order is owned by an engineer who can see the whole pipeline and talk to you directly on the order page. The routing engine runs on our own hardware, offline. Customers pay per board in US dollars — no seats, no subscriptions.</p> + </div> + </div> +</section> +<section class="section"><div class="container"><div class="cta-band"><h2>Build with us, or ship with us.</h2><div class="row"><a class="btn btn--primary btn--lg" href="/portal/new">Start an order</a><a class="btn btn--lg" href="/careers">See open roles</a></div></div></div></section> diff --git a/pcb-portal/views/site/careers.html b/pcb-portal/views/site/careers.html new file mode 100644 index 0000000..342cd36 --- /dev/null +++ b/pcb-portal/views/site/careers.html @@ -0,0 +1,21 @@ +<!--meta { + "title": "Careers — EDA, geometry and graphics engineers | QodeX PCB", + "description": "Join QodeX to build autonomous PCB routing: computational geometry, EDA file formats, signal integrity, and GPU-accelerated 3D board visualisation.", + "nav": "careers" +} --> +<section class="page-hero"> + <div class="container container--narrow"> + <span class="eyebrow">Careers</span> + <h1>Hard problems, measured in microns.</h1> + <p class="lead">We are a small team of hardware and software engineers. If you like problems where the answer is either manufacturable or it isn't, we'd like to hear from you.</p> + </div> +</section> +<section class="section--tight"> + <div class="container container--narrow stack-cards"> + <div class="card"><div class="card__title"><h3>Routing & computational geometry engineer</h3><span class="badge">Remote</span></div><p class="muted">Topological routing, constraint solving, polygon operations at scale. Rust or C++; experience with EDA or CAM is a plus.</p></div> + <div class="card"><div class="card__title"><h3>Signal-integrity engineer</h3><span class="badge">Remote</span></div><p class="muted">Turn SI/PI know-how into rules a router can enforce: impedance, return paths, skew and via transitions for DDR, PCIe and RF.</p></div> + <div class="card"><div class="card__title"><h3>Graphics engineer (WebGL / WebGPU)</h3><span class="badge">Remote</span></div><p class="muted">Make the 3D Studio render 100k-track boards at 60 fps in the browser. Three.js, GPU buffers, zero-allocation render loops.</p></div> + <div class="card"><div class="card__title"><h3>Field applications engineer</h3><span class="badge">Remote</span></div><p class="muted">Own customer orders end to end: review boards, talk to engineers, and turn their feedback into product.</p></div> + <p class="muted">Don't see your role? <a href="/contact?topic=careers">Tell us what you'd build</a>.</p> + </div> +</section> diff --git a/pcb-portal/views/site/contact.html b/pcb-portal/views/site/contact.html new file mode 100644 index 0000000..181d512 --- /dev/null +++ b/pcb-portal/views/site/contact.html @@ -0,0 +1,36 @@ +<!--meta { + "title": "Contact — talk to a PCB engineer | QodeX PCB", + "description": "Questions about a board, enterprise terms, NDAs or security? Send us a message and an engineer will reply.", + "nav": "contact", + "script": "contact" +} --> +<section class="page-hero"> + <div class="container split split--even"> + <div> + <span class="eyebrow">Contact</span> + <h1>Talk to an engineer, not a funnel.</h1> + <p class="lead">Questions about a specific board, volume pricing, NDAs or export control — send a message and a field applications engineer will reply by email.</p> + <ul class="checklist" style="margin-top:24px"> + <li>Board feasibility reviews before you order</li> + <li>Mutual NDA and invoicing for teams</li> + <li>Security questionnaires and vendor reviews</li> + </ul> + <p class="muted" style="margin-top:20px">Already have an order? Use the message thread on the order page — it goes straight to your engineer.</p> + </div> + <form class="card stack" id="contact-form" novalidate> + <div class="grid grid--2"> + <label class="field"><span class="field__label">Name</span><input type="text" name="name" autocomplete="name" required /></label> + <label class="field"><span class="field__label">Work email</span><input type="email" name="email" autocomplete="email" required /></label> + </div> + <div class="grid grid--2"> + <label class="field"><span class="field__label">Company</span><input type="text" name="company" autocomplete="organization" /></label> + <label class="field"><span class="field__label">Topic</span><select name="topic" id="topic"></select></label> + </div> + <label class="field"><span class="field__label">Message</span><textarea name="message" required minlength="10" placeholder="Layer count, board size, what makes it hard, timeline…"></textarea></label> + <label class="hp" aria-hidden="true">Website <input type="text" name="website" tabindex="-1" autocomplete="off" /></label> + <div class="form-error" id="error" hidden></div> + <div class="form-ok" id="ok" hidden>Thanks — your message is in. We'll reply by email.</div> + <button class="btn btn--primary btn--lg" type="submit">Send message</button> + </form> + </div> +</section> diff --git a/pcb-portal/views/site/docs.html b/pcb-portal/views/site/docs.html new file mode 100644 index 0000000..a7e3534 --- /dev/null +++ b/pcb-portal/views/site/docs.html @@ -0,0 +1,127 @@ +<!--meta { + "title": "Docs & DFM academy — preparing KiCad boards for routing | QodeX PCB", + "description": "How to prepare a KiCad board for QodeX routing: placement, net classes, differential pairs, stackup and impedance, fab profiles, file formats, and a DFM primer on acid traps, annular rings and teardrops.", + "nav": "docs" +} --> +<section class="page-hero"> + <div class="container"> + <span class="eyebrow">Documentation</span> + <h1>Docs & DFM academy</h1> + <p class="lead">Everything you need to prepare a board for routing, read the deliverables, and understand the manufacturing rules behind them.</p> + </div> +</section> +<section class="section--tight"> + <div class="container doc-layout"> + <nav class="toc" aria-label="Documentation"> + <h4>Getting started</h4> + <a href="#quickstart">Quick start</a> + <a href="#prepare">Preparing your board</a> + <a href="#netclasses">Net classes & pairs</a> + <a href="#stackup">Stackup & impedance</a> + <a href="#notes">Writing order notes</a> + <h4>Orders</h4> + <a href="#statuses">Order statuses</a> + <a href="#payments">Payments & receipts</a> + <a href="#formats">File formats</a> + <a href="#viewer">Using the 3D viewer</a> + <h4>DFM academy</h4> + <a href="#acid-traps">Acid traps</a> + <a href="#annular">Annular ring</a> + <a href="#teardrops">Teardrops</a> + <a href="#return-path">Return paths</a> + <a href="#glossary">Glossary</a> + </nav> + <article class="prose"> + <h2 id="quickstart">Quick start</h2> + <ol> + <li><a href="/register">Create an account</a>.</li> + <li>Go to <a href="/portal/new">New order</a> and drop your <code>.kicad_pcb</code>. Layers, size, nets and pads are extracted in the browser and a 3D preview appears.</li> + <li>Choose the service, fab house and options. The price updates live.</li> + <li>Place the order and pay by card, Apple Pay, Google Pay or PayPal.</li> + <li>Follow progress and chat with your engineer on the order page. Deliverables appear there with a 3D preview.</li> + </ol> + + <h2 id="prepare">Preparing your board</h2> + <ul> + <li><strong>Placement is final.</strong> Every footprint should be placed and oriented. We do not move parts.</li> + <li><strong>Outline on Edge.Cuts.</strong> A closed outline; cut-outs and slots as closed shapes inside it.</li> + <li><strong>Lock what must not change.</strong> Locked footprints and tracks are preserved exactly.</li> + <li><strong>Run DRC first.</strong> Unconnected-items warnings are expected; clearance errors on existing copper are not.</li> + <li><strong>Pre-route what you care about.</strong> Hand-routed tracks you leave in place are kept; we route the rest.</li> + </ul> + + <h2 id="netclasses">Net classes & differential pairs</h2> + <p>Define net classes in <em>Board Setup → Net Classes</em> with track width, clearance and via sizes. Differential pairs are detected from KiCad naming (<code>USB_D+</code>/<code>USB_D-</code>, <code>ETH_TX_P</code>/<code>ETH_TX_N</code>) and routed with the class's pair width and gap.</p> + <table><thead><tr><th>Setting</th><th>Where</th><th>Used for</th></tr></thead><tbody> + <tr><td>Track width, clearance</td><td>Net class</td><td>Every segment of the net</td></tr> + <tr><td>Diff-pair width / gap</td><td>Net class</td><td>Coupled routing and impedance</td></tr> + <tr><td>Via size / drill</td><td>Net class</td><td>Layer transitions</td></tr> + <tr><td>Custom rules</td><td><code>.kicad_dru</code></td><td>Region and condition-based constraints</td></tr> + </tbody></table> + + <h2 id="stackup">Stackup & impedance</h2> + <p>Impedance is solved from the stackup in <em>Board Setup → Physical Stackup</em>: dielectric thickness and εr per layer, copper thickness and finish. If you leave it at KiCad's defaults, we use the chosen fab's standard stackup for that layer count and tell you in the dossier.</p> + <div class="callout callout--cyan"><strong>Tip:</strong> state targets explicitly in the notes, e.g. <code>USB: 90 Ω diff ±10 %</code>, <code>RF_*: 50 Ω SE, grounded coplanar on F.Cu</code>.</div> + + <h2 id="notes">Writing order notes</h2> + <p>Good notes are short and specific. Examples:</p> +<pre><code>DDR4: byte lanes matched ±5 mil to DQS, addr/cmd fly-by ±25 mil +PCIe x4: 85 Ω diff, max 2 layer changes per lane +VIN_48V / SW: 12 A, keep on F.Cu + In2, 2 oz outer copper +Keep-out: nothing under U7 (crystal) on In1</code></pre> + + <h2 id="statuses">Order statuses</h2> + <table><thead><tr><th>Status</th><th>Meaning</th></tr></thead><tbody> + <tr><td>Awaiting quote</td><td>An engineer is reviewing the board before pricing (complex or unusual jobs).</td></tr> + <tr><td>Awaiting payment</td><td>Price is set; pay to start.</td></tr> + <tr><td>Paid — queued</td><td>Payment confirmed by the provider; waiting for a routing slot.</td></tr> + <tr><td>In progress</td><td>Routing and polish are running.</td></tr> + <tr><td>Delivered</td><td>Deliverables are on the order page. Review and confirm.</td></tr> + <tr><td>Completed</td><td>You confirmed receipt.</td></tr> + </tbody></table> + + <h2 id="payments">Payments & receipts</h2> + <p>All prices are in US dollars. Checkout is handled by Stripe (cards, Apple Pay, Google Pay, Link) or PayPal — we never see your card number. An order is marked paid only after the provider confirms the payment to our server. Receipts are available under <a href="/portal/billing">Billing</a> and can be printed or saved as PDF.</p> + + <h2 id="formats">File formats</h2> + <table><thead><tr><th>Type</th><th>Extensions</th><th>3D preview</th></tr></thead><tbody> + <tr><td>KiCad board</td><td><code>.kicad_pcb</code></td><td>Yes — full layer model</td></tr> + <tr><td>KiCad project</td><td><code>.kicad_pro .kicad_sch .kicad_dru .zip</code></td><td>—</td></tr> + <tr><td>3D models</td><td><code>.glb .gltf .stl .obj .wrl</code></td><td>Yes</td></tr> + <tr><td>Fabrication</td><td>Gerber (<code>.gbr .gtl .gbl …</code>), <code>.drl</code>, <code>.ipc</code></td><td>—</td></tr> + <tr><td>Documents</td><td><code>.pdf .png .jpg .txt .md .csv .xlsx</code></td><td>—</td></tr> + </tbody></table> + + <h2 id="viewer">Using the 3D viewer</h2> + <table><thead><tr><th>Control</th><th>Action</th></tr></thead><tbody> + <tr><td>Left drag / right drag / wheel</td><td>Orbit / pan / zoom</td></tr> + <tr><td>Inspect</td><td>Hover to identify pads, vias, tracks and parts; click to highlight the whole net with its routed length</td></tr> + <tr><td>Measure</td><td>Click two points: distance, ΔX, ΔY and mils</td></tr> + <tr><td>Layers</td><td>Toggle copper and silk layers, fade the mask, explode the stack</td></tr> + <tr><td><kbd>Esc</kbd></td><td>Leave the current tool and clear the selection</td></tr> + </tbody></table> + + <h2 id="acid-traps">DFM academy · Acid traps</h2> + <p>An acid trap is an acute angle (less than 90°) between two copper features. During etching, etchant pools in the narrow corner and over-etches it, which can open the trace or leave it thin. The fix is geometric: join tracks at 90° or wider, and mitre or arc bends.</p> + + <h3 id="annular">Annular ring</h3> + <p>The annular ring is the copper left around a drilled hole: <code>(pad diameter − drill) / 2</code>. Drill wander can break out of a ring that is too small. Fabs publish a minimum (commonly 0.1–0.15 mm on standard service); we check every via and pad against the selected fab's value.</p> + + <h3 id="teardrops">Teardrops</h3> + <p>A teardrop is a tapered fillet where a track meets a pad or via. It adds copper where drill wander or thermal stress would otherwise crack a narrow neck, and improves yield on thin tracks and flex regions.</p> + + <h3 id="return-path">Return paths</h3> + <p>Every signal current returns through the nearest reference plane, directly beneath the trace at high frequency. If the plane has a split or void under the trace, the return current detours, creating a loop antenna and an impedance discontinuity. That is why QodeX treats plane continuity under constrained nets as a routing constraint.</p> + + <h2 id="glossary">Glossary</h2> + <table><tbody> + <tr><td><strong>DRC</strong></td><td>Design rule check — clearances, widths, connectivity.</td></tr> + <tr><td><strong>DFM</strong></td><td>Design for manufacturing — geometry that improves fabrication yield.</td></tr> + <tr><td><strong>Escape routing</strong></td><td>Getting signals out from under a dense package such as a BGA.</td></tr> + <tr><td><strong>Skew</strong></td><td>Arrival-time difference between the two halves of a differential pair.</td></tr> + <tr><td><strong>Stitching via</strong></td><td>A ground via placed next to a signal via to give its return current a short path between planes.</td></tr> + <tr><td><strong>Microvia</strong></td><td>A small laser-drilled via spanning one dielectric layer, used in HDI boards.</td></tr> + </tbody></table> + </article> + </div> +</section> diff --git a/pcb-portal/views/site/evidence.html b/pcb-portal/views/site/evidence.html new file mode 100644 index 0000000..15cd0c7 --- /dev/null +++ b/pcb-portal/views/site/evidence.html @@ -0,0 +1,50 @@ +<!--meta { + "title": "Evidence library — what a QodeX delivery dossier contains | QodeX PCB", + "description": "Every QodeX order ships with an evidence dossier: DRC and DFM results against your fab's rules, constrained-net tables for impedance, skew and length, and a 3D model for review.", + "nav": "evidence", + "script": "evidence" +} --> +<section class="page-hero"> + <div class="container"> + <span class="eyebrow">Evidence library</span> + <h1>Don't trust the router. Check the evidence.</h1> + <p class="lead">Every delivery includes a dossier that lists each rule we checked and the result. Below is a sample built from our open demo board, so you can see exactly what you will receive.</p> + </div> +</section> + +<section class="section--tight"> + <div class="container split"> + <div class="card" style="padding:0;overflow:hidden"><div class="viewer-box" id="viewer" style="height:520px"></div></div> + <aside class="stack-cards"> + <div class="card"> + <div class="card__title"><h3>Sample dossier summary</h3><span class="badge badge--completed">PASS</span></div> + <dl class="kv" id="summary"></dl> + </div> + <div class="card"> + <h3>Rule results</h3> + <table class="table"><tbody> + <tr><td>Clearance (track–track, track–pad)</td><td><span class="badge badge--completed">0 violations</span></td></tr> + <tr><td>Minimum track width</td><td><span class="badge badge--completed">0 violations</span></td></tr> + <tr><td>Annular ring</td><td><span class="badge badge--completed">0 violations</span></td></tr> + <tr><td>Acid traps (< 90°)</td><td><span class="badge badge--completed">0 found</span></td></tr> + <tr><td>Unconnected items</td><td><span class="badge badge--completed">0</span></td></tr> + </tbody></table> + </div> + </aside> + </div> +</section> + +<section class="section"> + <div class="container"> + <div class="section__head"><span class="eyebrow">Constrained nets</span><h2>Net report</h2><p class="lead">Routed length, pads and vias for every net on the demo board — computed live from the file in your browser. Click a net name to highlight it in the 3D view.</p></div> + <div class="card table-wrap"><table class="table" id="nets"><thead><tr><th>Net</th><th class="num">Routed length</th><th class="num">Pads</th><th class="num">Vias</th><th class="num">Segments</th></tr></thead><tbody></tbody></table></div> + </div> +</section> + +<section class="section"> + <div class="container grid grid--3"> + <div class="card feature"><span class="feature__num">SECTION 1</span><h3>Board & stackup</h3><p>Outline, layer count, stackup and materials, finish, and the fab profile the board was checked against.</p></div> + <div class="card feature"><span class="feature__num">SECTION 2</span><h3>Rule results</h3><p>Every DRC and DFM rule with its limit, the worst-case measured value and its location on the board.</p></div> + <div class="card feature"><span class="feature__num">SECTION 3</span><h3>Constrained nets</h3><p>Impedance, skew and length results for every pair and group you defined, with pass/fail against your targets.</p></div> + </div> +</section> diff --git a/pcb-portal/views/site/home.html b/pcb-portal/views/site/home.html new file mode 100644 index 0000000..00391ba --- /dev/null +++ b/pcb-portal/views/site/home.html @@ -0,0 +1,189 @@ +<!--meta { + "title": "QodeX PCB — Autonomous KiCad PCB routing & DFM polishing", + "description": "Upload a placed KiCad board and get it back routed, impedance-matched and DFM-polished, with a 3D model, fabrication outputs and an evidence dossier. Transparent USD pricing, pay by card or PayPal.", + "script": "home", + "nav": "home", + "jsonLd": { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + "@id": "{{baseUrl}}/#org", + "name": "{{companyName}}", + "url": "{{baseUrl}}/", + "logo": "{{baseUrl}}/favicon.svg" + }, + { + "@type": "Service", + "name": "{{siteName}} — autonomous PCB routing", + "serviceType": "Printed circuit board routing and DFM review", + "provider": { "@id": "{{baseUrl}}/#org" }, + "areaServed": "Worldwide", + "offers": { "@type": "Offer", "priceCurrency": "USD", "url": "{{baseUrl}}/pricing" } + }, + { + "@type": "SoftwareApplication", + "name": "{{siteName}} 3D Studio", + "applicationCategory": "DesignApplication", + "operatingSystem": "Web browser", + "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }, + "featureList": ["KiCad .kicad_pcb 3D viewer", "Net highlighting", "Layer explode", "Measurement", "glTF / STL / OBJ / VRML viewer"] + } + ] + } +} --> +<section class="hero"> + <div class="container hero__grid"> + <div> + <span class="eyebrow">Autonomous PCB routing · KiCad native</span> + <h1>Intent in.<br /><span class="grad">Evidence out.</span></h1> + <p class="lead">Send us a placed KiCad board. It comes back routed, impedance-matched and DFM-polished — with a lossless <code>.kicad_pcb</code>, a 3D model, fab outputs and a signed-off evidence dossier. You never leave KiCad.</p> + <div class="row hero__cta"> + <a class="btn btn--primary btn--lg" href="/portal/new">Start an order</a> + <a class="btn btn--lg" href="/studio">Open the 3D Studio</a> + </div> + <div class="hero__proof"> + <span>Lossless KiCad round-trip</span> + <span>Your files never train models</span> + <span>USD pricing · card or PayPal</span> + </div> + </div> + <div class="hero__stage"> + <div id="hero-viewer" style="position:absolute;inset:0"></div> + <span class="hero__badge">Live render · click Inspect, then any trace</span> + </div> + </div> +</section> + +<section class="section--tight" style="padding-top:8px"> + <div class="container"> + <dl class="telemetry" id="telemetry" aria-label="Demo board telemetry"> + <div><dt>Nets resolved</dt><dd data-k="nets">—</dd></div> + <div><dt>Pads</dt><dd data-k="pads">—</dd></div> + <div><dt>Vias placed</dt><dd data-k="vias">—</dd></div> + <div><dt>Copper routed</dt><dd data-k="length">—<small>mm</small></dd></div> + </dl> + <p class="low" style="margin-top:10px">Telemetry of the demo board above, computed live in your browser from its <code>.kicad_pcb</code>.</p> + </div> +</section> + +<section class="section" id="how"> + <div class="container"> + <div class="section__head"> + <span class="eyebrow">The pipeline</span> + <h2>Four stages. No black box.</h2> + <p class="lead">Every stage is logged against your order, so you can see what happened to your board and why.</p> + </div> + <div class="pipeline"> + <div class="pipeline__step"><span class="pipeline__tag">01 · PARSE</span><h3>Lossless intake</h3><p>Your board is parsed into a comment-preserving S-expression tree. Placement, locked items, 3D model paths and custom metadata are carried through untouched.</p></div> + <div class="pipeline__step"><span class="pipeline__tag">02 · CLASSIFY</span><h3>Constraint extraction</h3><p>Net classes, differential pairs, length groups and impedance targets are read from your project and bound to the chosen fab's capability table.</p></div> + <div class="pipeline__step"><span class="pipeline__tag">03 · ROUTE</span><h3>Topological routing</h3><p>BGA escape, then constrained groups, then everything else — solved on the topology first and committed to geometry last, with return paths as a constraint.</p></div> + <div class="pipeline__step"><span class="pipeline__tag">04 · POLISH</span><h3>DFM polish & dossier</h3><p>Acid traps removed, teardrops added, slivers and annular rings checked. You get the board plus a report of every rule it passed.</p></div> + </div> + <p style="margin-top:22px"><a class="link-arrow" href="/how-it-works">Read the full technical walkthrough</a></p> + </div> +</section> + +<section class="section"> + <div class="container split--even split"> + <div> + <span class="eyebrow">DFM polish</span> + <h2>The difference is in the corners.</h2> + <p class="lead">Drag the handle. On the left, a typical auto-routed fan-out: right angles, acute acid traps and bare pad entries. On the right, the same nets after the QodeX polish pass.</p> + <ul class="checklist" style="margin-top:22px"> + <li>No 90° corners — mitred or arced bends only</li> + <li>Acute angles below 90° eliminated (acid-trap free)</li> + <li>Teardrops on every pad and via entry</li> + <li>Ground stitching next to every layer change</li> + </ul> + </div> + <div> + <div class="compare" id="compare" role="slider" aria-label="Before and after comparison" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" tabindex="0"> + <canvas id="compare-before"></canvas> + <div class="compare__after" id="compare-after"><canvas id="compare-after-canvas"></canvas></div> + <div class="compare__handle" id="compare-handle"></div> + <span class="compare__label compare__label--before">Before · DRC warnings</span> + <span class="compare__label compare__label--after">After · QodeX polish</span> + </div> + <div class="compare__legend"><span>Acid traps <b id="cmp-traps">7 → 0</b></span><span>90° corners <b>12 → 0</b></span><span>Teardrops <b>0 → 16</b></span></div> + </div> + </div> +</section> + +<section class="section"> + <div class="container"> + <div class="section__head"> + <span class="eyebrow">Fab-aware by default</span> + <h2>Routed to your fab's rules, not generic ones.</h2> + <p class="lead">Pick a fabricator when you order and its capability table becomes a hard constraint for routing and polish.</p> + </div> + <div class="fab-grid"> + <div class="fab"><h4>JLCPCB</h4><p>Standard multilayer service</p><dl><dt>Trace / space</dt><dd>0.09 / 0.09 mm</dd><dt>Min via drill</dt><dd>0.15 mm</dd><dt>Annular ring</dt><dd>0.13 mm</dd><dt>Layers</dt><dd>1 – 16</dd></dl></div> + <div class="fab"><h4>PCBWay</h4><p>Standard PCB service</p><dl><dt>Trace / space</dt><dd>0.1 / 0.1 mm</dd><dt>Min via drill</dt><dd>0.2 mm</dd><dt>Annular ring</dt><dd>0.15 mm</dd><dt>Layers</dt><dd>1 – 14</dd></dl></div> + <div class="fab"><h4>Eurocircuits</h4><p>Pattern class 6</p><dl><dt>Trace / space</dt><dd>0.125 / 0.125 mm</dd><dt>Min via drill</dt><dd>0.25 mm</dd><dt>Annular ring</dt><dd>0.125 mm</dd><dt>Layers</dt><dd>1 – 16</dd></dl></div> + <div class="fab"><h4>Sierra Circuits</h4><p>Standard technology</p><dl><dt>Trace / space</dt><dd>0.1 / 0.1 mm</dd><dt>Min via drill</dt><dd>0.2 mm</dd><dt>Annular ring</dt><dd>0.1 mm</dd><dt>Layers</dt><dd>1 – 16+</dd></dl></div> + </div> + <p class="low" style="margin-top:14px">Indicative standard-service values. The authoritative table for your order is confirmed by our engineers against the fab's current capabilities.</p> + </div> +</section> + +<section class="section"> + <div class="container split split--even"> + <div class="viewer-box" id="studio-teaser" style="height:420px"></div> + <div> + <span class="eyebrow">3D Studio</span> + <h2>Inspect every net before it hits copper.</h2> + <p class="lead">The same viewer that ships with every order. Open any <code>.kicad_pcb</code>, GLB, STL, OBJ or VRML file — nothing leaves your browser.</p> + <div class="grid grid--2" style="margin-top:24px"> + <div><h4>Net highlighting</h4><p class="muted">Click a pad, via or trace to light up the whole net across every layer, with its routed length.</p></div> + <div><h4>Layer explode</h4><p class="muted">Pull the stack apart and fade the solder mask to inspect inner planes.</p></div> + <div><h4>Precision measure</h4><p class="muted">Two clicks give distance, ΔX / ΔY and mils — on the board, not the screen.</p></div> + <div><h4>Any format</h4><p class="muted">KiCad 5 to 9, glTF, STL, OBJ and KiCad's VRML export.</p></div> + </div> + <p style="margin-top:20px"><a class="btn btn--primary" href="/studio">Open the Studio</a></p> + </div> + </div> +</section> + +<section class="section"> + <div class="container"> + <div class="section__head"> + <span class="eyebrow">Use cases</span> + <h2>Built for the boards autorouters gave up on.</h2> + </div> + <div class="grid grid--3">{{{useCaseCards}}}</div> + </div> +</section> + +<section class="section"> + <div class="container grid grid--3"> + <div class="card feature"><div class="feature__icon">IP</div><h3>Your IP stays yours</h3><p>Files are stored privately, served only to your account and our assigned engineer, and are never used to train AI models. A mutual NDA is available on request.</p></div> + <div class="card feature"><div class="feature__icon">⌁</div><h3>Offline routing hardware</h3><p>Routing runs on dedicated machines that are not exposed to the internet. Only finished deliverables come back to the portal.</p></div> + <div class="card feature"><div class="feature__icon">$</div><h3>Transparent USD pricing</h3><p>The quote is computed from your actual board — layers, area, nets and pads — before you pay. Card, Apple Pay, Google Pay or PayPal.</p></div> + </div> +</section> + +<section class="section"> + <div class="container container--narrow"> + <div class="section__head center center"> + <span class="eyebrow">FAQ</span> + <h2>Questions engineers ask first</h2> + </div> + <details class="faq"><summary>Will you move my components?</summary><p>No. Placement is your design intent. If a constraint cannot be met with the current placement, the dossier tells you exactly which parts and nets are in conflict instead of silently moving anything.</p></details> + <details class="faq"><summary>Does the file come back exactly as I sent it?</summary><p>Everything we did not route — comments, formatting, footprints, 3D model paths, custom fields — is preserved byte-for-byte. Git diffs show only the copper we added.</p></details> + <details class="faq"><summary>Which KiCad versions are supported?</summary><p>KiCad 6, 7, 8 and 9 boards are routed natively. KiCad 5 boards are accepted and upgraded on intake.</p></details> + <details class="faq"><summary>How is the price calculated?</summary><p>From the board itself: service, layer count, area, number of nets and pads, plus options such as controlled impedance or express turnaround. Try the <a href="/pricing">estimator</a>.</p></details> + <details class="faq"><summary>How do I pay?</summary><p>All prices are in US dollars. Pay by credit or debit card, Apple Pay or Google Pay through Stripe, or with PayPal. Every payment gets a downloadable receipt.</p></details> + </div> +</section> + +<section class="section"> + <div class="container"> + <div class="cta-band"> + <span class="eyebrow">Ready when your placement is</span> + <h2>Upload your board. See the quote in seconds.</h2> + <p>Your board is analysed in the browser the moment you drop it — layers, size, nets, pads — and priced before you commit.</p> + <div class="row"><a class="btn btn--primary btn--lg" href="/portal/new">Start an order</a><a class="btn btn--lg" href="/pricing">Estimate a price</a></div> + </div> + </div> +</section> diff --git a/pcb-portal/views/site/how-it-works.html b/pcb-portal/views/site/how-it-works.html new file mode 100644 index 0000000..37ee1b1 --- /dev/null +++ b/pcb-portal/views/site/how-it-works.html @@ -0,0 +1,123 @@ +<!--meta { + "title": "How it works — topological PCB routing pipeline | QodeX PCB", + "description": "A technical walkthrough of the QodeX pipeline: lossless KiCad parsing, constraint extraction, topological routing with return-path awareness, and DFM polishing with an evidence dossier.", + "nav": "how", + "jsonLd": { + "@context": "https://schema.org", + "@type": "HowTo", + "name": "How QodeX routes a KiCad PCB", + "step": [ + { "@type": "HowToStep", "name": "Upload a placed board", "text": "Upload your .kicad_pcb with components placed and net classes defined." }, + { "@type": "HowToStep", "name": "Confirm the quote and pay", "text": "The price is computed from the board itself; pay in USD by card or PayPal." }, + { "@type": "HowToStep", "name": "Routing and DFM polish", "text": "The board is routed against your constraints and the fab's capability table, then polished." }, + { "@type": "HowToStep", "name": "Review and download", "text": "Inspect the routed board in 3D and download the KiCad file, fab outputs and evidence dossier." } + ] + } +} --> +<section class="page-hero"> + <div class="container"> + <span class="eyebrow">How it works</span> + <h1>A compiler for copper.</h1> + <p class="lead">QodeX treats your board the way a compiler treats source code: parse it without loss, extract the constraints, solve the problem in an abstract space, then emit geometry and a proof that it satisfies every rule.</p> + </div> +</section> + +<section class="section--tight"> + <div class="container"> + <div class="pipeline"> + <div class="pipeline__step"><span class="pipeline__tag">01 · PARSE</span><h3>Lossless intake</h3><p>Comment-preserving S-expression tree.</p></div> + <div class="pipeline__step"><span class="pipeline__tag">02 · CLASSIFY</span><h3>Constraints</h3><p>Net classes, pairs, groups, fab table.</p></div> + <div class="pipeline__step"><span class="pipeline__tag">03 · ROUTE</span><h3>Topology first</h3><p>Escape, constrained groups, the rest.</p></div> + <div class="pipeline__step"><span class="pipeline__tag">04 · POLISH</span><h3>DFM & dossier</h3><p>Geometry clean-up and evidence.</p></div> + </div> + </div> +</section> + +<section class="section"> + <div class="container doc-layout"> + <nav class="toc" aria-label="On this page"> + <h4>Stages</h4> + <a href="#parse">01 · Lossless intake</a> + <a href="#classify">02 · Constraint extraction</a> + <a href="#route">03 · Topological routing</a> + <a href="#polish">04 · DFM polish</a> + <h4>Delivery</h4> + <a href="#deliverables">What you receive</a> + <a href="#workflow">Order workflow</a> + <a href="#limits">When we say no</a> + </nav> + <article class="prose"> + <h2 id="parse">01 · Lossless intake</h2> + <p>Most tools re-serialise a board after reading it, which reorders fields, drops comments and turns a small change into a thousand-line diff. QodeX parses the <code>.kicad_pcb</code> into a concrete syntax tree that remembers every token, including whitespace and comments.</p> + <p>Only the nodes we create — tracks, arcs, vias and zone fills — are ever written. Everything else is emitted byte-for-byte from the original file, so your Git history shows exactly the copper that was added.</p> + <div class="callout"><strong>Invariant:</strong> footprints, their positions, locked items, 3D model paths, custom fields and design rules you did not ask us to touch are never modified.</div> + + <h2 id="classify">02 · Constraint extraction</h2> + <p>Constraints come from three places, in this order of precedence:</p> + <ol> + <li><strong>Your KiCad project</strong> — net classes, differential-pair naming, custom design rules (<code>.kicad_dru</code>), rule areas and the board stackup.</li> + <li><strong>Your order notes</strong> — impedance targets, length windows, currents per net, locked regions.</li> + <li><strong>The fab profile</strong> — minimum trace/space, drill sizes, annular ring, aspect ratio and via technologies of the fabricator you selected.</li> + </ol> + <p>Where your rules are tighter than the fab's, yours win. Where they are looser, the fab's are applied so the board is manufacturable as delivered.</p> + <table> + <thead><tr><th>Constraint</th><th>Source</th><th>Checked in dossier</th></tr></thead> + <tbody> + <tr><td>Trace width / clearance</td><td>Net class ∩ fab profile</td><td>Every segment</td></tr> + <tr><td>Differential impedance</td><td>Stackup + target</td><td>Per pair, per layer</td></tr> + <tr><td>Intra-pair skew</td><td>Order notes / rules</td><td>Per pair</td></tr> + <tr><td>Length window</td><td>Group definition</td><td>Per group</td></tr> + <tr><td>Annular ring, drill</td><td>Fab profile</td><td>Every via and pad</td></tr> + </tbody> + </table> + + <h2 id="route">03 · Topological routing</h2> + <p>Routing is solved on the board's topology before any coordinates are fixed: which side of each obstacle a net passes, in what order nets cross a channel, and which layer each segment uses. Geometry is committed only once the topology is known to be feasible.</p> + <h3>Order of operations</h3> + <ol> + <li><strong>Escape routing</strong> for BGAs and fine-pitch parts, ring by ring, using the via technology your fab supports.</li> + <li><strong>Constrained groups</strong> — differential pairs, length-matched buses, RF feeds — routed together with their return paths.</li> + <li><strong>Power distribution</strong> sized for current, with via arrays for thermal and current capacity.</li> + <li><strong>General signals</strong>, with layer changes minimised and a stitching via next to every reference change.</li> + </ol> + <h3>Return paths are constraints, not afterthoughts</h3> + <p>For every constrained net we check the reference plane beneath it along its whole length. Crossing a plane split, an anti-pad void or a slot is treated like a clearance violation: the route is rejected and re-solved.</p> + + <h2 id="polish">04 · DFM polish</h2> + <p>The polish pass cleans up geometry that is electrically fine but hurts yield:</p> + <ul class="checklist"> + <li>Acute angles (acid traps) removed; bends mitred to 45° or arced</li> + <li>Teardrops added at pad and via entries</li> + <li>Copper slivers and starved thermals removed from pours</li> + <li>Annular ring and drill-to-copper checked against the fab table</li> + <li>Silkscreen clipped from pads and solder-mask openings</li> + </ul> + + <h2 id="deliverables">What you receive</h2> + <table> + <thead><tr><th>File</th><th>Purpose</th></tr></thead> + <tbody> + <tr><td><code>board.kicad_pcb</code></td><td>Your board, routed. Opens in KiCad unchanged except for new copper.</td></tr> + <tr><td><code>board.glb</code></td><td>3D model for review in the portal or any glTF viewer.</td></tr> + <tr><td><code>fab/*.gbr, *.drl</code></td><td>Gerber X2 and Excellon drill files for the selected fab.</td></tr> + <tr><td><code>dossier.pdf</code></td><td>DRC / DFM evidence: every rule, every result, constrained-net tables.</td></tr> + </tbody> + </table> + + <h2 id="workflow">Order workflow</h2> + <ol> + <li>Upload the board in the portal. It is analysed in your browser and priced instantly.</li> + <li>Pay in USD by card, Apple Pay, Google Pay or PayPal.</li> + <li>Your order is queued; status updates and engineer messages appear in the portal.</li> + <li>Deliverables are uploaded to your order. Inspect them in 3D, download, and confirm.</li> + </ol> + + <h2 id="limits">When we say no</h2> + <p>Some boards cannot be routed as placed — a connector wedged against a BGA, a four-layer stack asked to carry eight layers of signals. When that happens you get a report showing the congested regions and the smallest change that would make the board routable (for example, two extra layers, or moving three named parts), and you decide what to do. We do not silently move parts or relax your rules.</p> + </article> + </div> +</section> + +<section class="section"> + <div class="container"><div class="cta-band"><h2>See it on your own board.</h2><p>Upload a placed <code>.kicad_pcb</code> and get a quote in seconds.</p><div class="row"><a class="btn btn--primary btn--lg" href="/portal/new">Start an order</a><a class="btn btn--lg" href="/studio">Try the 3D Studio</a></div></div></div> +</section> diff --git a/pcb-portal/views/site/integrations.html b/pcb-portal/views/site/integrations.html new file mode 100644 index 0000000..f337435 --- /dev/null +++ b/pcb-portal/views/site/integrations.html @@ -0,0 +1,71 @@ +<!--meta { + "title": "KiCad integration — lossless round-trip, Git-friendly | QodeX PCB", + "description": "QodeX works with the KiCad files you already have. Lossless S-expression round-trip for KiCad 6–9, net classes and design rules honoured, and a clean Git diff containing only new copper.", + "nav": "integrations" +} --> +<section class="page-hero"> + <div class="container"> + <span class="eyebrow">Integrations</span> + <h1>Stay in KiCad. We fit into your Git flow.</h1> + <p class="lead">No new EDA tool, no conversion, no re-drawn footprints. You send the board you already have and get the same file back with copper added — ready to open, review and commit.</p> + </div> +</section> + +<section class="section--tight"> + <div class="container grid grid--3"> + <div class="card feature"><div class="feature__icon">6–9</div><h3>KiCad 6, 7, 8 and 9</h3><p>Boards from every current KiCad major version are routed natively. KiCad 5 files are accepted and upgraded on intake.</p></div> + <div class="card feature"><div class="feature__icon">Δ</div><h3>Diff-clean output</h3><p>Untouched nodes are emitted byte-for-byte, so <code>git diff</code> shows only new tracks, vias and fills.</p></div> + <div class="card feature"><div class="feature__icon">⚙</div><h3>Your rules, respected</h3><p>Net classes, differential pairs, custom <code>.kicad_dru</code> rules, rule areas and the stackup are all read from your project.</p></div> + </div> +</section> + +<section class="section"> + <div class="container split split--even"> + <div> + <span class="eyebrow">Recommended workflow</span> + <h2>Branch, upload, review, merge.</h2> + <ol class="muted" style="line-height:2"> + <li>Finish placement and net classes; commit on a branch.</li> + <li>Upload the <code>.kicad_pcb</code> (or the zipped project) in the portal.</li> + <li>Download the routed board into the same path.</li> + <li>Review the diff and the 3D model, run KiCad DRC, merge.</li> + </ol> + </div> +<pre><code>$ git switch -c route/rev-b +$ git add hardware/ && git commit -m "placement frozen for routing" +# upload hardware/controller.kicad_pcb at /portal/new +# … routed board delivered … +$ cp ~/Downloads/controller.kicad_pcb hardware/ +$ git diff --stat + hardware/controller.kicad_pcb | 1843 +++++++++ + 1 file changed, 1843 insertions(+) +$ kicad-cli pcb drc hardware/controller.kicad_pcb +Found 0 DRC violations</code></pre> + </div> +</section> + +<section class="section"> + <div class="container"> + <div class="section__head"><span class="eyebrow">What to send</span><h2>File checklist</h2></div> + <div class="grid grid--2"> + <div class="card"><h3>Required</h3><ul class="checklist"><li><code>.kicad_pcb</code> with every footprint placed</li><li>Board outline on <code>Edge.Cuts</code></li><li>Net classes for anything non-default</li></ul></div> + <div class="card"><h3>Recommended</h3><ul class="checklist"><li>Zipped project (<code>.kicad_pro</code>, <code>.kicad_sch</code>, <code>.kicad_dru</code>)</li><li>Stackup defined in Board Setup</li><li>Locked footprints/tracks you want untouched</li><li>Notes: impedance targets, currents, length windows</li></ul></div> + </div> + </div> +</section> + +<section class="section"> + <div class="container split split--even"> + <div> + <span class="eyebrow">Other tools</span> + <h2>Altium, OrCAD, EAGLE?</h2> + <p class="muted">We focus on KiCad so the round-trip is truly lossless. If you work in another tool, KiCad can import Altium, CADSTAR, EAGLE and Fabmaster boards — import, save as <code>.kicad_pcb</code> and upload. Tell us in the notes and we'll review the import before routing.</p> + </div> + <div class="card"> + <h3>3D outputs</h3> + <p class="muted">Every order includes a GLB you can open in the portal or any glTF viewer. Want to make your own? In KiCad 8/9:</p> + <pre><code>kicad-cli pcb export glb -o board.glb board.kicad_pcb</code></pre> + <p class="muted" style="margin:0">Then drop it into the <a href="/studio">3D Studio</a>.</p> + </div> + </div> +</section> diff --git a/pcb-portal/views/site/legal.html b/pcb-portal/views/site/legal.html new file mode 100644 index 0000000..d0e394c --- /dev/null +++ b/pcb-portal/views/site/legal.html @@ -0,0 +1,74 @@ +<!--meta { + "title": "Legal — terms, privacy, IP, data processing, export | QodeX PCB", + "description": "Terms of service, privacy policy, intellectual-property and confidentiality commitments, data processing terms and export compliance for QodeX PCB.", + "nav": "legal" +} --> +<section class="page-hero"> + <div class="container"> + <span class="eyebrow">Legal</span> + <h1>Terms, privacy and IP</h1> + <p class="lead">Written to be read. If anything is unclear, <a href="/contact">ask us</a>.</p> + </div> +</section> +<section class="section--tight"> + <div class="container doc-layout"> + <nav class="toc" aria-label="Legal documents"> + <a href="#terms">Terms of service</a> + <a href="#ip">IP & confidentiality</a> + <a href="#privacy">Privacy policy</a> + <a href="#dpa">Data processing</a> + <a href="#payments">Payments & refunds</a> + <a href="#export">Export compliance</a> + </nav> + <article class="prose"> + <h2 id="terms">Terms of service</h2> + <p>These terms govern your use of {{siteName}}, operated by the company named in the site footer ("we", "us"). By creating an account or placing an order you agree to them.</p> + <h3>Services</h3> + <p>We provide PCB routing, DFM review and related engineering services on board files you upload, and optionally arrange fabrication. Deliverables are described on each order.</p> + <h3>Your responsibilities</h3> + <ul> + <li>You confirm you have the right to share the files you upload.</li> + <li>You are responsible for reviewing deliverables, running your own DRC and verifying the design before fabrication or production.</li> + <li>You must not upload malicious files or use the service to infringe anyone's rights.</li> + </ul> + <h3>Limitation of liability</h3> + <p>Engineering deliverables are provided with reasonable skill and care. To the maximum extent permitted by law, our total liability for any order is limited to the amount you paid for that order, and we are not liable for indirect or consequential losses, including the cost of fabricated or assembled boards.</p> + + <h2 id="ip">IP & confidentiality</h2> + <ul> + <li><strong>You own your designs.</strong> Uploading a file gives us a limited licence to process it solely to perform your order.</li> + <li><strong>You own the deliverables.</strong> On payment, all rights in the routed board and other deliverables for your order belong to you.</li> + <li><strong>No AI training.</strong> We do not use customer files, geometry or metadata to train, fine-tune or evaluate machine-learning models.</li> + <li><strong>Confidentiality.</strong> Your files are treated as confidential information, accessed only by staff working on your order, and not disclosed to third parties except the fabricator you select for fabrication services.</li> + <li><strong>NDA.</strong> A mutual NDA is available on request and takes precedence over this section where they conflict.</li> + </ul> + + <h2 id="privacy">Privacy policy</h2> + <h3>What we collect</h3> + <table><thead><tr><th>Data</th><th>Why</th></tr></thead><tbody> + <tr><td>Name, email, company, phone, country</td><td>Account, communication, receipts</td></tr> + <tr><td>Uploaded files and order messages</td><td>Performing your order</td></tr> + <tr><td>Payment references (not card numbers)</td><td>Accounting and support</td></tr> + <tr><td>Session cookie</td><td>Keeping you signed in (strictly necessary)</td></tr> + <tr><td>Contact-form submissions</td><td>Replying to you</td></tr> + </tbody></table> + <p>We do not use advertising or cross-site tracking cookies. Card details are entered on Stripe's or PayPal's pages and never reach our servers.</p> + <h3>Retention and your rights</h3> + <p>Account and order records are kept for as long as your account is active and as required for accounting. You can ask us to access, correct, export or delete your personal data and order files by contacting us; we respond within 30 days.</p> + + <h2 id="dpa">Data processing</h2> + <p>Where we process personal data on your behalf, we do so only on your documented instructions, keep it confidential, apply appropriate technical and organisational security measures, and delete or return it at the end of the service. Sub-processors:</p> + <table><thead><tr><th>Sub-processor</th><th>Purpose</th></tr></thead><tbody> + <tr><td>Stripe</td><td>Card, Apple Pay and Google Pay payments</td></tr> + <tr><td>PayPal</td><td>PayPal payments</td></tr> + <tr><td>Selected fabricator</td><td>Fabrication orders only, when you choose that service</td></tr> + </tbody></table> + + <h2 id="payments">Payments & refunds</h2> + <p>All prices are in US dollars and are shown before payment. Payment is taken when you place the order. If we accept an order and cannot deliver it, we refund it in full. If you cancel before work starts, we refund in full; after work starts, contact us and we will refund any unused portion fairly.</p> + + <h2 id="export">Export compliance</h2> + <p>You are responsible for determining whether your design is subject to export-control laws (for example the U.S. ITAR or EAR, or EU dual-use regulations). Do not upload export-controlled technical data unless we have confirmed in writing that we can accept it and how it will be handled. We may refuse or cancel orders to comply with applicable sanctions and export laws.</p> + </article> + </div> +</section> diff --git a/pcb-portal/views/site/login.html b/pcb-portal/views/site/login.html new file mode 100644 index 0000000..1cbdb82 --- /dev/null +++ b/pcb-portal/views/site/login.html @@ -0,0 +1,20 @@ +<!--meta {"title": "Sign in | QodeX PCB", "robots": "noindex", "shell": "bare", "script": "auth", "nav": "login"} --> +<div class="auth"> + <div class="auth__panel"> + <div class="auth__box"> + <span class="eyebrow">Welcome back</span> + <h1>Sign in</h1> + <form id="form" class="stack" novalidate data-mode="login"> + <label class="field"><span class="field__label">Email</span><input type="email" name="email" autocomplete="email" required autofocus /></label> + <label class="field"><span class="field__label">Password</span><input type="password" name="password" autocomplete="current-password" required /></label> + <div class="form-error" id="error" hidden></div> + <button class="btn btn--primary btn--lg btn--block" type="submit">Sign in</button> + <p class="low">New to QodeX? <a href="/register" id="alt">Create an account</a></p> + </form> + </div> + </div> + <div class="auth__art" aria-hidden="true"> + <svg class="traces" viewBox="0 0 600 800" preserveAspectRatio="xMidYMid slice"><g fill="none" stroke="#df8244" stroke-width="3" stroke-linecap="round" opacity=".55"><path d="M60 120h180l60 60v220l60 60h180"/><path d="M60 160h160l60 60v220l60 60h200"/><path d="M60 200h140l60 60v220l60 60h220"/><path d="M60 240h120l60 60v220l60 60h240"/></g><g fill="#ffb076"><circle cx="60" cy="120" r="7"/><circle cx="60" cy="160" r="7"/><circle cx="60" cy="200" r="7"/><circle cx="60" cy="240" r="7"/></g><g fill="none" stroke="#2ce5e5" stroke-width="2" opacity=".4"><path d="M100 700h400"/><path d="M100 720h400"/></g></svg> + <blockquote>"Placement is intent. Copper is physics. Evidence beats trust."<cite>— the QodeX engineering manifesto</cite></blockquote> + </div> +</div> diff --git a/pcb-portal/views/site/pricing.html b/pcb-portal/views/site/pricing.html new file mode 100644 index 0000000..89cfc54 --- /dev/null +++ b/pcb-portal/views/site/pricing.html @@ -0,0 +1,71 @@ +<!--meta { + "title": "Pricing & estimator — PCB routing in USD | QodeX PCB", + "description": "Transparent USD pricing for PCB routing, DFM review and fabrication. Estimate your price from layers, board size, nets and pads. Pay by card, Apple Pay, Google Pay or PayPal.", + "nav": "pricing", + "script": "pricing" +} --> +<section class="page-hero"> + <div class="container"> + <span class="eyebrow">Pricing</span> + <h1>Priced by the board, not by the seat.</h1> + <p class="lead">No subscriptions, no credits to expire. Each order is priced from the board you upload — layers, area, nets and pads — and shown to you before you pay. All prices in US dollars.</p> + </div> +</section> + +<section class="section--tight"> + <div class="container split"> + <form class="card stack" id="estimator" novalidate> + <div class="card__title"><h2>Estimator</h2><span class="low">Same formula as checkout</span></div> + <label class="field"><span class="field__label">Service</span><select name="service" id="service"></select></label> + <div class="grid grid--2"> + <label class="field"><span class="field__label">Copper layers <b id="layers-out" class="accent mono"></b></span><input type="range" name="layersIdx" id="layers" min="0" max="8" step="1" value="2" /></label> + <label class="field"><span class="field__label">Quantity (fabrication)</span><input type="number" name="quantity" value="10" min="1" max="100000" /></label> + </div> + <div class="grid grid--2"> + <label class="field"><span class="field__label">Width (mm)</span><input type="number" name="widthMm" value="80" min="3" max="1000" step="0.1" /></label> + <label class="field"><span class="field__label">Height (mm)</span><input type="number" name="heightMm" value="60" min="3" max="1000" step="0.1" /></label> + </div> + <div class="grid grid--2"> + <label class="field"><span class="field__label">Nets</span><input type="number" name="nets" value="150" min="0" /></label> + <label class="field"><span class="field__label">Pads</span><input type="number" name="pads" value="600" min="0" /></label> + </div> + <div class="grid grid--2"> + <label class="field"><span class="field__label">Surface finish</span><select name="finish" id="finish"></select></label> + <label class="field"><span class="field__label">Turnaround</span><select name="turnaround" id="turnaround"></select></label> + </div> + <label class="check"><input type="checkbox" name="impedanceControl" /> Controlled impedance</label> + <label class="check"><input type="checkbox" name="hasBGA" /> Board has BGA packages</label> + <p class="low" style="margin:0">Tip: drop your <code>.kicad_pcb</code> on the <a href="/portal/new">order page</a> and all of this is filled in from the file.</p> + </form> + <aside class="card" style="position:sticky;top:88px"> + <span class="eyebrow">Estimated total</span> + <div class="price-big" id="price">—</div> + <ul class="price-lines" id="lines"></ul> + <a class="btn btn--primary btn--block btn--lg" style="margin-top:20px" href="/portal/new">Start this order</a> + <p class="low" style="margin-top:12px">Pay by card, Apple Pay, Google Pay or PayPal. Receipt issued for every payment.</p> + </aside> + </div> +</section> + +<section class="section"> + <div class="container"> + <div class="section__head"><span class="eyebrow">Services</span><h2>What each service includes</h2></div> + <div class="grid grid--4" id="services"></div> + </div> +</section> + +<section class="section"> + <div class="container grid grid--2"> + <div class="card"> + <h3>Team & enterprise</h3> + <p class="muted">Volume pricing, invoicing on net terms, a mutual NDA before the first file, a named engineer and priority queueing. For export-controlled or regulated work, routing can be restricted to specific staff and hardware.</p> + <a class="btn" href="/contact?topic=sales">Contact sales</a> + </div> + <div class="card"> + <h3>Pricing FAQ</h3> + <details class="faq"><summary>Is the estimate the final price?</summary><p>If you upload a board, the price at checkout is computed from the file itself with the same formula. Some complex boards are quoted manually by an engineer before payment.</p></details> + <details class="faq"><summary>What if you can't route my board?</summary><p>If the board cannot be routed as placed, we tell you why before routing starts. If we accept the job and cannot deliver, you are refunded.</p></details> + <details class="faq"><summary>Which currencies can I pay in?</summary><p>All prices are charged in USD. Your card issuer or PayPal handles conversion from your local currency.</p></details> + </div> + </div> +</section> diff --git a/pcb-portal/views/site/register.html b/pcb-portal/views/site/register.html new file mode 100644 index 0000000..045bc60 --- /dev/null +++ b/pcb-portal/views/site/register.html @@ -0,0 +1,26 @@ +<!--meta {"title": "Create an account | QodeX PCB", "robots": "noindex", "shell": "bare", "script": "auth", "nav": "register"} --> +<div class="auth"> + <div class="auth__panel"> + <div class="auth__box"> + <span class="eyebrow">Get started</span> + <h1>Create your account</h1> + <form id="form" class="stack" novalidate data-mode="register"> + <label class="field"><span class="field__label">Full name</span><input type="text" name="name" autocomplete="name" required autofocus /></label> + <label class="field"><span class="field__label">Work email</span><input type="email" name="email" autocomplete="email" required /></label> + <div class="grid grid--2"> + <label class="field"><span class="field__label">Company <span class="low">(optional)</span></span><input type="text" name="company" autocomplete="organization" /></label> + <label class="field"><span class="field__label">Country <span class="low">(optional)</span></span><input type="text" name="country" autocomplete="country-name" /></label> + </div> + <label class="field"><span class="field__label">Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required /><span class="field__hint">At least 8 characters.</span></label> + <label class="check"><input type="checkbox" name="acceptTerms" required /> <span>I agree to the <a href="/legal#terms" target="_blank">Terms of Service</a> and the <a href="/legal#ip" target="_blank">IP & confidentiality policy</a>.</span></label> + <div class="form-error" id="error" hidden></div> + <button class="btn btn--primary btn--lg btn--block" type="submit">Create account</button> + <p class="low">Already have an account? <a href="/login" id="alt">Sign in</a></p> + </form> + </div> + </div> + <div class="auth__art" aria-hidden="true"> + <svg class="traces" viewBox="0 0 600 800" preserveAspectRatio="xMidYMid slice"><g fill="none" stroke="#df8244" stroke-width="3" stroke-linecap="round" opacity=".55"><path d="M540 100H360l-60 60v260l-60 60H60"/><path d="M540 140H380l-60 60v260l-60 60H60"/><path d="M540 180H400l-60 60v260l-60 60H60"/></g><g fill="#ffb076"><circle cx="540" cy="100" r="7"/><circle cx="540" cy="140" r="7"/><circle cx="540" cy="180" r="7"/></g></svg> + <blockquote>Upload a placed board, see the price in seconds, and get back a routed <span class="accent">.kicad_pcb</span> with the evidence to prove it.<cite>USD pricing · card, Apple Pay, Google Pay, PayPal</cite></blockquote> + </div> +</div> diff --git a/pcb-portal/views/site/studio.html b/pcb-portal/views/site/studio.html new file mode 100644 index 0000000..62fe62b --- /dev/null +++ b/pcb-portal/views/site/studio.html @@ -0,0 +1,30 @@ +<!--meta { + "title": "3D Studio — free online KiCad PCB & glTF viewer | QodeX PCB", + "description": "Open any .kicad_pcb, GLB, glTF, STL, OBJ or VRML file in your browser. Highlight nets, explode the layer stack, fade the solder mask and measure. Files never leave your machine.", + "shell": "bare", + "script": "studio", + "nav": "studio", + "robots": "index,follow", + "jsonLd": { + "@context": "https://schema.org", + "@type": "WebApplication", + "name": "QodeX 3D Studio", + "applicationCategory": "DesignApplication", + "operatingSystem": "Any (web browser)", + "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" } + } +} --> +<h1 class="hp">QodeX 3D Studio — online KiCad PCB viewer</h1> +<div class="studio" id="studio"> + <div class="studio__drop" id="drop"><div id="viewer" style="position:absolute;inset:0"></div></div> + <div class="studio__bar"> + <label class="btn btn--primary btn--sm">Open file…<input type="file" id="file" hidden accept=".kicad_pcb,.glb,.gltf,.stl,.obj,.wrl" /></label> + <select id="mask" class="btn--sm" style="width:auto;padding:5px 30px 5px 10px;font-size:13px" aria-label="Solder-mask colour"> + <option value="green">Green mask</option><option value="black">Black mask</option><option value="matte_black">Matte black</option><option value="blue">Blue mask</option><option value="red">Red mask</option><option value="white">White mask</option><option value="purple">Purple mask</option><option value="yellow">Yellow mask</option> + </select> + <select id="finish" style="width:auto;padding:5px 30px 5px 10px;font-size:13px" aria-label="Surface finish"> + <option value="enig">ENIG</option><option value="hasl">HASL</option><option value="osp">OSP</option><option value="immersion_silver">Immersion silver</option><option value="hard_gold">Hard gold</option> + </select> + <span class="low" id="fname">demo-board.kicad_pcb</span> + </div> +</div> diff --git a/pcb-portal/views/site/trust.html b/pcb-portal/views/site/trust.html new file mode 100644 index 0000000..8a9ae0c --- /dev/null +++ b/pcb-portal/views/site/trust.html @@ -0,0 +1,63 @@ +<!--meta { + "title": "Trust & security — how QodeX protects your PCB designs | QodeX PCB", + "description": "How QodeX handles your design files: private storage, access limited to your account and your engineer, offline routing hardware, no AI training on customer data, payment data handled by Stripe and PayPal.", + "nav": "trust" +} --> +<section class="page-hero"> + <div class="container"> + <span class="eyebrow">Trust & security</span> + <h1>Your board is your IP. We treat it that way.</h1> + <p class="lead">A plain-language description of how your files move through QodeX, who can see them, and what we will never do with them.</p> + </div> +</section> + +<section class="section--tight"> + <div class="container grid grid--4"> + <div class="card feature"><div class="feature__icon">01</div><h3>Private storage</h3><p>Uploads are stored outside any public path under random identifiers and served only through an authenticated, ownership-checked endpoint.</p></div> + <div class="card feature"><div class="feature__icon">02</div><h3>Least access</h3><p>Only your account and the engineer assigned to your order can download your files. Every upload, status change and payment is logged on the order.</p></div> + <div class="card feature"><div class="feature__icon">03</div><h3>Offline routing</h3><p>Routing runs on dedicated machines that are not reachable from the internet. Only finished deliverables are uploaded back to your order.</p></div> + <div class="card feature"><div class="feature__icon">04</div><h3>No model training</h3><p>Customer files, copper geometry and metadata are never used to train or fine-tune AI models. This is written into our <a href="/legal#ip">terms</a>.</p></div> + </div> +</section> + +<section class="section"> + <div class="container split split--even"> + <div> + <span class="eyebrow">Data flow</span> + <h2>Where your file goes</h2> + <ol class="muted" style="line-height:1.9"> + <li><strong>Your browser</strong> parses the board locally to show the preview and quote. Nothing is sent until you place the order.</li> + <li><strong>Upload over TLS</strong> to private portal storage, attached to your order only.</li> + <li><strong>Engineer workstation</strong> retrieves it for routing on offline hardware.</li> + <li><strong>Deliverables</strong> are uploaded to your order; you are notified in the portal.</li> + <li><strong>Deletion</strong> of order files is available on request once the order is complete.</li> + </ol> + </div> + <div class="card"> + <h3>Controls at a glance</h3> + <table class="table"><tbody> + <tr><td>Transport</td><td>HTTPS / TLS everywhere, HSTS</td></tr> + <tr><td>Passwords</td><td>scrypt-hashed, never stored in plain text</td></tr> + <tr><td>Sessions</td><td>HttpOnly, SameSite cookies; CSRF-guarded API</td></tr> + <tr><td>Browser hardening</td><td>Strict Content-Security-Policy, no third-party scripts</td></tr> + <tr><td>Payments</td><td>Hosted by Stripe / PayPal; card data never touches our servers</td></tr> + <tr><td>Payment integrity</td><td>Server-to-server verification of every payment</td></tr> + <tr><td>Uploads</td><td>Extension allow-list, size limits, integrity hashes (SHA-256)</td></tr> + </tbody></table> + </div> + </div> +</section> + +<section class="section"> + <div class="container grid grid--2"> + <div class="card"><h3>Mutual NDA</h3><p class="muted">Need an NDA before sending anything? We sign mutual NDAs for team and enterprise customers before the first file is uploaded.</p><a class="btn" href="/contact?topic=security">Request an NDA</a></div> + <div class="card"><h3>Export-controlled designs</h3><p class="muted">If your design is subject to export controls (for example ITAR or EAR), contact us before uploading so we can confirm whether and how we can handle it. Do not upload controlled technical data without that confirmation.</p><a class="btn" href="/legal#export">Export compliance</a></div> + </div> +</section> + +<section class="section"> + <div class="container container--narrow"> + <h2>Report a vulnerability</h2> + <p class="muted">If you believe you have found a security issue, please email us via the <a href="/contact?topic=security">contact page</a> with the details. We will acknowledge within two business days and keep you updated until it is resolved. Please do not access other customers' data or degrade the service while testing.</p> + </div> +</section> diff --git a/pcb-portal/views/site/use-case.html b/pcb-portal/views/site/use-case.html new file mode 100644 index 0000000..7e82063 --- /dev/null +++ b/pcb-portal/views/site/use-case.html @@ -0,0 +1,36 @@ +<!--meta {"title": "Use case | QodeX PCB", "nav": "use-cases"} --> +<script type="application/ld+json">{{{ucFaqJson}}}</script> +<section class="page-hero"> + <div class="container"> + <span class="eyebrow">{{ucName}} · {{ucStandard}}</span> + <h1>{{ucHeadline}}</h1> + <p class="lead">{{ucIntro}}</p> + <div class="row" style="margin-top:28px"><a class="btn btn--primary btn--lg" href="/portal/new">Start a {{ucName}} order</a><a class="btn btn--lg" href="/pricing">Estimate a price</a></div> + </div> +</section> +<section class="section--tight"><div class="container"><dl class="specs">{{{ucSpecs}}}</dl></div></section> +<section class="section"> + <div class="container"> + <div class="section__head"><span class="eyebrow">The hard parts</span><h2>What goes wrong on {{ucName}} boards</h2></div> + <div class="grid grid--3">{{{ucChallenges}}}</div> + </div> +</section> +<section class="section"> + <div class="container split split--even"> + <div> + <span class="eyebrow">Our approach</span> + <h2>How QodeX handles it</h2> + <ul class="checklist">{{{ucApproach}}}</ul> + </div> + <div> + <h3>Frequently asked</h3> + {{{ucFaq}}} + </div> + </div> +</section> +<section class="section"> + <div class="container"> + <h3>Other use cases</h3> + <div>{{{ucOthers}}}</div> + </div> +</section> diff --git a/pcb-portal/views/site/use-cases.html b/pcb-portal/views/site/use-cases.html new file mode 100644 index 0000000..de77d1b --- /dev/null +++ b/pcb-portal/views/site/use-cases.html @@ -0,0 +1,14 @@ +<!--meta { + "title": "Use cases — RF, high-speed, power, mixed-signal, flex, HDI | QodeX PCB", + "description": "How QodeX routes RF and microwave feeds, DDR and PCIe buses, high-current power stages, mixed-signal boards, flex and rigid-flex, and HDI boards with fine-pitch BGAs.", + "nav": "use-cases" +} --> +<section class="page-hero"> + <div class="container"> + <span class="eyebrow">Use cases</span> + <h1>Every board class has its own physics.</h1> + <p class="lead">The same pipeline, tuned per domain: the constraints that matter for an RF front end are not the ones that matter for a 40 A buck converter.</p> + </div> +</section> +<section class="section--tight"><div class="container grid grid--3">{{{useCaseCards}}}</div></section> +<section class="section"><div class="container"><div class="cta-band"><h2>Your board doesn't fit a category?</h2><p>Most real boards are several of these at once. Tell us what matters in the order notes, or talk to an engineer first.</p><div class="row"><a class="btn btn--primary btn--lg" href="/portal/new">Start an order</a><a class="btn btn--lg" href="/contact">Talk to an engineer</a></div></div></div></section> From 7c3d583c8739224b27cc9af0e9e72fe424ba5dee Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 30 Sep 2026 13:54:32 +0000 Subject: [PATCH 2/2] fix(pcb-portal): address CodeQL findings - Rate limiting: use express-rate-limit on the customer and admin API routers (plus stricter limits on auth, contact and the Stripe webhook). - Cookies: read the session cookie by name instead of building an object keyed by header input (remote property injection). - Login redirect: follow `next` only when it resolves to this origin and prefix it with location.origin (a `/\evil.com` value previously passed). - Password change moved to POST /api/auth/password, which always verifies the current password; PATCH /api/auth/me only updates the profile. - Package description updated for Stripe / PayPal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BQf54SD6EFwPyF5xPtqv8 --- pcb-portal/package-lock.json | 16 ++++++++++ pcb-portal/package.json | 7 +++-- pcb-portal/public/js/pages/auth.js | 15 ++++++++-- pcb-portal/public/js/pages/settings.js | 2 +- pcb-portal/server/app.js | 4 +-- pcb-portal/server/auth.js | 41 +++++++++++--------------- pcb-portal/server/routes/admin.js | 4 +-- pcb-portal/server/routes/api.js | 31 +++++++++++-------- pcb-portal/test/api.test.js | 14 ++++++++- 9 files changed, 87 insertions(+), 47 deletions(-) diff --git a/pcb-portal/package-lock.json b/pcb-portal/package-lock.json index bf3758c..8b00fe2 100644 --- a/pcb-portal/package-lock.json +++ b/pcb-portal/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "better-sqlite3": "^11.5.0", "express": "^5.1.0", + "express-rate-limit": "^7.5.1", "multer": "^2.0.2", "three": "^0.180.0" }, @@ -438,6 +439,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", diff --git a/pcb-portal/package.json b/pcb-portal/package.json index 57510f1..72de5f1 100644 --- a/pcb-portal/package.json +++ b/pcb-portal/package.json @@ -2,10 +2,12 @@ "name": "@qodex/pcb-portal", "version": "0.1.0", "private": true, - "description": "QodeX PCB — customer ordering portal: order intake, online payment (Zarinpal / Stripe), file exchange and in-browser 3D viewer for KiCad & GLB boards. Routing itself runs offline on the QodeX engine.", + "description": "QodeX PCB — international website, customer ordering portal, USD payments (Stripe, PayPal), file exchange and in-browser 3D Studio for KiCad and glTF boards. Routing itself runs offline on the QodeX engine.", "type": "module", "license": "Apache-2.0", - "engines": { "node": ">=20.0.0" }, + "engines": { + "node": ">=20.0.0" + }, "scripts": { "start": "node server/index.js", "dev": "node --watch server/index.js", @@ -15,6 +17,7 @@ "dependencies": { "better-sqlite3": "^11.5.0", "express": "^5.1.0", + "express-rate-limit": "^7.5.1", "multer": "^2.0.2", "three": "^0.180.0" } diff --git a/pcb-portal/public/js/pages/auth.js b/pcb-portal/public/js/pages/auth.js index 5f9b997..d3de645 100644 --- a/pcb-portal/public/js/pages/auth.js +++ b/pcb-portal/public/js/pages/auth.js @@ -1,8 +1,17 @@ import { api, qs } from '../app.js'; const form = document.getElementById('form'); -const next = qs('next'); -const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : null; +/** Only follow `next` when it resolves to a page on this site. */ +function sameOriginPath(value) { + if (!value) return null; + try { + const url = new URL(value, location.origin); + return url.origin === location.origin ? `${url.pathname}${url.search}${url.hash}` : null; + } catch { + return null; + } +} +const safeNext = sameOriginPath(qs('next')); const alt = document.getElementById('alt'); if (alt && safeNext) alt.href += `?next=${encodeURIComponent(safeNext)}`; const errorBox = document.getElementById('error'); @@ -15,7 +24,7 @@ form.addEventListener('submit', async (e) => { btn.disabled = true; try { const { user } = await api(isRegister ? '/auth/register' : '/auth/login', { method: 'POST', body: Object.fromEntries(new FormData(form)) }); - location.href = safeNext || (user.role === 'admin' ? '/admin' : '/portal'); + location.href = `${location.origin}${safeNext || (user.role === 'admin' ? '/admin' : '/portal')}`; } catch (err) { errorBox.textContent = err.message; errorBox.hidden = false; diff --git a/pcb-portal/public/js/pages/settings.js b/pcb-portal/public/js/pages/settings.js index a50e8d9..6fbd68c 100644 --- a/pcb-portal/public/js/pages/settings.js +++ b/pcb-portal/public/js/pages/settings.js @@ -18,7 +18,7 @@ profile.addEventListener('submit', async (e) => { pw.addEventListener('submit', async (e) => { e.preventDefault(); try { - await api('/auth/me', { method: 'PATCH', body: Object.fromEntries(new FormData(pw)) }); + await api('/auth/password', { method: 'POST', body: Object.fromEntries(new FormData(pw)) }); pw.reset(); toast('Password changed. Other sessions were signed out.'); } catch (err) { diff --git a/pcb-portal/server/app.js b/pcb-portal/server/app.js index 9c86854..a1fef58 100644 --- a/pcb-portal/server/app.js +++ b/pcb-portal/server/app.js @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'; import { join } from 'node:path'; import { ROOT } from './config.js'; import { openDb, getSetting, setSetting } from './db.js'; -import { csrfGuard, hashPassword, sessionMiddleware } from './auth.js'; +import { csrfGuard, hashPassword, limiter, sessionMiddleware } from './auth.js'; import { createStorage } from './storage.js'; import { createPaymentProviders } from './payments/index.js'; import { createPaymentService } from './payments/service.js'; @@ -99,7 +99,7 @@ export async function createApp(config, deps = {}) { // Stripe webhook needs the raw body for signature verification, so it is // mounted before the JSON parser and outside the CSRF guard. - app.post('/api/payments/stripe/webhook', express.raw({ type: '*/*', limit: '1mb' }), async (req, res) => { + app.post('/api/payments/stripe/webhook', limiter({ windowMs: 60e3, limit: 300 }), express.raw({ type: '*/*', limit: '1mb' }), async (req, res) => { const stripe = providers.get('stripe'); const event = stripe?.parseWebhook(req.body, req.get('stripe-signature')); if (!event) return res.status(400).json({ error: 'Invalid signature.' }); diff --git a/pcb-portal/server/auth.js b/pcb-portal/server/auth.js index d2566f1..f314996 100644 --- a/pcb-portal/server/auth.js +++ b/pcb-portal/server/auth.js @@ -1,5 +1,6 @@ import { createHash, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; +import { rateLimit as expressRateLimit } from 'express-rate-limit'; const scrypt = promisify(scryptCb); export const SESSION_COOKIE = 'qx_sid'; @@ -32,20 +33,19 @@ export function destroySession(db, token) { if (token) db.prepare('DELETE FROM sessions WHERE token_hash = ?').run(sha256(token)); } -export function parseCookies(header) { - const out = {}; +/** Read one cookie by name (no generic parsing into an object keyed by user input). */ +export function readCookie(header, name) { for (const part of String(header || '').split(';')) { const i = part.indexOf('='); - if (i < 0) continue; - const k = part.slice(0, i).trim(); - if (!k) continue; + if (i < 0 || part.slice(0, i).trim() !== name) continue; + const raw = part.slice(i + 1).trim(); try { - out[k] = decodeURIComponent(part.slice(i + 1).trim()); + return decodeURIComponent(raw); } catch { - out[k] = part.slice(i + 1).trim(); + return raw; } } - return out; + return null; } export function sessionCookie(token, { secure, expires }) { @@ -69,7 +69,7 @@ export function sessionMiddleware(db) { purge.run(); lastPurge = Date.now(); } - const token = parseCookies(req.headers.cookie)[SESSION_COOKIE]; + const token = readCookie(req.headers.cookie, SESSION_COOKIE); req.sessionToken = token || null; req.user = null; if (token) { @@ -105,18 +105,13 @@ export function csrfGuard(req, res, next) { next(); } -/** Tiny fixed-window rate limiter keyed by IP + bucket. */ -export function rateLimit({ windowMs, max, bucket }) { - const hits = new Map(); - return (req, res, next) => { - const now = Date.now(); - const key = `${bucket}:${req.ip}`; - let h = hits.get(key); - if (!h || now - h.start > windowMs) h = { start: now, count: 0 }; - h.count++; - hits.set(key, h); - if (hits.size > 10000) for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); - if (h.count > max) return res.status(429).json({ error: 'Too many requests. Please try again in a few minutes.' }); - next(); - }; +/** Rate limiter (express-rate-limit) with a JSON error body. */ +export function limiter({ windowMs, limit }) { + return expressRateLimit({ + windowMs, + limit, + standardHeaders: 'draft-7', + legacyHeaders: false, + message: { error: 'Too many requests. Please try again in a few minutes.' }, + }); } diff --git a/pcb-portal/server/routes/admin.js b/pcb-portal/server/routes/admin.js index ee69398..714e4ae 100644 --- a/pcb-portal/server/routes/admin.js +++ b/pcb-portal/server/routes/admin.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { requireAdmin } from '../auth.js'; +import { limiter, requireAdmin } from '../auth.js'; import { getSetting, logEvent, setSetting } from '../db.js'; import { defaultPricing, validatePricing } from '../pricing.js'; import { HttpError, STATUS_LABELS, asyncH, cleanText, cleanupTemp, orderDTO, orderDetail, storeUploads, uploader } from './shared.js'; @@ -13,7 +13,7 @@ export function adminRouter(ctx) { const { db, config } = ctx; const r = Router(); const upload = uploader(ctx, 30); - r.use(requireAdmin); + r.use(limiter({ windowMs: 5 * 60e3, limit: 1000 }), requireAdmin); const findOrder = (code) => { const o = db.prepare(`${ORDER_JOIN} WHERE o.code = ?`).get(String(code)); diff --git a/pcb-portal/server/routes/api.js b/pcb-portal/server/routes/api.js index c8149d6..851921c 100644 --- a/pcb-portal/server/routes/api.js +++ b/pcb-portal/server/routes/api.js @@ -3,7 +3,7 @@ import { createSession, destroySession, hashPassword, - rateLimit, + limiter, requireUser, sessionCookie, verifyPassword, @@ -38,8 +38,9 @@ export function apiRouter(ctx) { const { db, config, payments } = ctx; const r = Router(); const upload = uploader(ctx); - const authLimit = rateLimit({ windowMs: 10 * 60e3, max: 20, bucket: 'auth' }); - const contactLimit = rateLimit({ windowMs: 60 * 60e3, max: 10, bucket: 'contact' }); + const authLimit = limiter({ windowMs: 10 * 60e3, limit: 20 }); + const contactLimit = limiter({ windowMs: 60 * 60e3, limit: 10 }); + r.use(limiter({ windowMs: 5 * 60e3, limit: 1000 })); const userDTO = (u) => u && { id: u.id, email: u.email, name: u.name, phone: u.phone, company: u.company, country: u.country, role: u.role }; const setCookie = (res, s) => res.setHeader('Set-Cookie', sessionCookie(s.token, { secure: config.secureCookies, expires: s.expires })); @@ -103,7 +104,7 @@ export function apiRouter(ctx) { r.get('/auth/me', (req, res) => res.json({ user: userDTO(req.user) })); - r.patch('/auth/me', requireUser, asyncH(async (req, res) => { + r.patch('/auth/me', requireUser, (req, res) => { const b = req.body || {}; const name = cleanText(b.name ?? req.user.name, 120); if (name.length < 2) throw new HttpError(400, 'Enter your name.'); @@ -114,16 +115,20 @@ export function apiRouter(ctx) { cleanText(b.country ?? req.user.country, 60) || null, req.user.id, ); - if (b.newPassword) { - const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); - if (!(await verifyPassword(String(b.currentPassword ?? ''), u.password_hash))) throw new HttpError(400, 'Current password is incorrect.'); - if (String(b.newPassword).length < 8) throw new HttpError(400, 'New password must be at least 8 characters.'); - db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(await hashPassword(String(b.newPassword)), req.user.id); - // sign out every other session - db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.user.id); - setCookie(res, createSession(db, req.user.id)); - } res.json({ user: userDTO(db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id)) }); + }); + + r.post('/auth/password', requireUser, authLimit, asyncH(async (req, res) => { + const current = String(req.body?.currentPassword ?? ''); + const next = String(req.body?.newPassword ?? ''); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); + if (!(await verifyPassword(current, u.password_hash))) throw new HttpError(400, 'Current password is incorrect.'); + if (next.length < 8) throw new HttpError(400, 'New password must be at least 8 characters.'); + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(await hashPassword(next), req.user.id); + // sign out every other session + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.user.id); + setCookie(res, createSession(db, req.user.id)); + res.json({ ok: true }); })); // ── public: quote estimator + contact ── diff --git a/pcb-portal/test/api.test.js b/pcb-portal/test/api.test.js index 6f4103b..5fdd798 100644 --- a/pcb-portal/test/api.test.js +++ b/pcb-portal/test/api.test.js @@ -115,6 +115,18 @@ describe('portal API (mock checkout)', () => { assert.equal((await c.post('/api/auth/login', { json: { email: 'alice@test.io', password: 'nope-nope' } })).status, 401); }); + test('password change always verifies the current password', async () => { + const c = client(app.base); + await register(c, 'Erin', 'erin@test.io'); + assert.equal((await c.post('/api/auth/password', { json: { currentPassword: 'wrong-one', newPassword: 'brand-new-pass' } })).status, 400); + assert.equal((await c.post('/api/auth/password', { json: { currentPassword: 'Erin-password', newPassword: 'short' } })).status, 400); + assert.equal((await c.post('/api/auth/password', { json: { currentPassword: 'Erin-password', newPassword: 'brand-new-pass' } })).status, 200); + assert.equal((await c.patch('/api/auth/me', { json: { name: 'Erin B', newPassword: 'sneaky-change' } })).status, 200); + assert.equal((await client(app.base).post('/api/auth/login', { json: { email: 'erin@test.io', password: 'brand-new-pass' } })).status, 200, 'profile update must not change the password'); + const limited = await c.get('/api/auth/me'); + assert.ok(limited.headers.get('ratelimit') || limited.headers.get('ratelimit-policy'), 'API responses carry rate-limit headers'); + }); + test('portal and admin pages require the right role', async () => { const r = await fetch(app.base + '/portal', { redirect: 'manual' }); assert.equal(r.status, 302); @@ -358,7 +370,7 @@ test('Stripe verify rejects amount / reference mismatches', async () => { }); test('config validation', () => { - assert.throws(() => readConfig({ PAYMENT_PROVIDERS: 'zarinpal' }), /Unknown payment provider/); + assert.throws(() => readConfig({ PAYMENT_PROVIDERS: 'wire' }), /Unknown payment provider/); assert.throws(() => readConfig({ PAYMENT_PROVIDERS: 'stripe' }), /STRIPE_SECRET_KEY/); assert.throws(() => readConfig({ PAYMENT_PROVIDERS: 'paypal', PAYPAL_CLIENT_ID: 'x' }), /PAYPAL_CLIENT_SECRET/); assert.equal(readConfig({}).currency, 'USD');