diff --git a/.gitignore b/.gitignore index 86ce498..0c5622a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ /out/ /.open-next/ /.wrangler/ +.wrangler-secrets.tmp.json # production /build diff --git a/README.md b/README.md index 57e5ba8..7f27902 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,55 @@ -# Hey, we're Stackwise π +
+ International software engineering with African roots β AI systems, cloud infrastructure, + and digital products that are secure, scalable, and beautifully designed. +
+ +
+
+
-
+
+
+
+
+
Building the future, one line of code at a time.
diff --git a/deploy/growth-connect/Dockerfile b/deploy/growth-connect/Dockerfile
new file mode 100644
index 0000000..0655786
--- /dev/null
+++ b/deploy/growth-connect/Dockerfile
@@ -0,0 +1,12 @@
+FROM mcr.microsoft.com/playwright:v1.49.1-noble
+
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev
+COPY scripts/growth-browser-lib.mjs scripts/growth-connect-server.mjs ./scripts/
+
+ENV GROWTH_CONNECT_HOST=0.0.0.0
+ENV GROWTH_CONNECT_PORT=8080
+EXPOSE 8080
+
+CMD ["node", "scripts/growth-connect-server.mjs"]
diff --git a/deploy/growth-connect/README.md b/deploy/growth-connect/README.md
new file mode 100644
index 0000000..f0db714
--- /dev/null
+++ b/deploy/growth-connect/README.md
@@ -0,0 +1,18 @@
+# Hosted growth connect (optional)
+
+Use this when you **cannot** use Cloudflare Browser Rendering on the main Worker but still want connect **without a local terminal**.
+
+1. Deploy this container (Fly.io, Render, Railway, etc.).
+2. Set secrets on the container:
+ - `GROWTH_CONNECTOR_SECRET` (same as production Worker)
+ - `GROWTH_API_BASE` (e.g. `https://stackwise-technologies.com`)
+3. On the **Cloudflare Worker**, set:
+ - `GROWTH_CONNECT_VIEWER_BASE` = public URL of this service (e.g. `https://growth-connect.fly.dev`)
+
+The admin UI will proxy connect traffic through `/api/admin/growth/connect/relay` (same-origin iframe). No terminal on your laptop.
+
+Build from repo root:
+
+```bash
+docker build -f deploy/growth-connect/Dockerfile -t growth-connect .
+```
diff --git a/docs/GROWTH_ENGINE.md b/docs/GROWTH_ENGINE.md
new file mode 100644
index 0000000..ef0cc23
--- /dev/null
+++ b/docs/GROWTH_ENGINE.md
@@ -0,0 +1,166 @@
+# Growth engine (content + leads)
+
+Admin UI under `/admin/growth` scores inbound social signals, drafts replies for human review, and stores **encrypted** LinkedIn / X session cookies for a **local connector** (no official posting APIs in phase 1).
+
+Discovery calls use [Cal.com](https://cal.com/stackwise-technologies/30min) via `bookingUrl()` in `src/lib/booking.ts` (guest `tshikombadaniel@gmail.com` always prefilled).
+
+## Architecture: Cloudflare vs Playwright
+
+| Layer | Where it runs | Role |
+|--------|----------------|------|
+| **Control plane** | Cloudflare Workers + Neon (this Next app) | Admin inbox, ICP scoring, encrypted session storage, health status APIs |
+| **Data plane** | Your Mac or a small VPS | Playwright: headed login, human-paced read-only harvest, optional publish later |
+
+**Playwright does not run on Cloudflare Workers.** Workers have no Chromium, no long-lived browser, and tight CPU/time limits. The pattern matches [cmnd-ai-api](https://github.com/cypruscodes/cmnd-ai-api) `cmndWebScraperBatchContainer`: stealth browser flags, human delays, and batch jobs on **AWS Batch** β not inside the API Lambda/Worker itself.
+
+Session flow (similar to cmnd `testConnection` + `AppConnectionStatus`):
+
+1. **Connect** β real browser login locally β cookies uploaded encrypted β `session_status = active`.
+2. **Harvest** β connector pulls cookies, opens feed with jitter/delays β pushes signals β touches `last_sync_at`.
+3. **Health** β connector reports `active` / `expired` / `challenge` to `/api/admin/growth/sessions/health`; admin UI shows reconnect when needed.
+
+## Setup
+
+1. Run migrations: `npm run db:migrate`
+2. Sign in to admin as usual.
+3. Optional Cal.com override:
+
+```bash
+NEXT_PUBLIC_CAL_COM_BOOKING_URL=https://cal.com/stackwise-technologies/30min
+```
+
+4. Connector secret (local machine + production Worker):
+
+```bash
+GROWTH_CONNECTOR_SECRET=your-long-random-secret-here
+```
+
+Uses the same encryption root as other app secrets (`KPAY_WEBHOOK_SECRET` / `KPAY_SECRET_KEY` via `getAppSecret()`).
+
+5. **OpenRouter** (personalized reply drafts after harvest / capture):
+
+```bash
+OPENROUTER_API_KEY=sk-or-β¦
+OPENROUTER_MODEL=openrouter/auto # or a specific model from openrouter.ai/models
+OPENROUTER_FALLBACK_MODEL=google/gemini-2.5-flash # non-reasoning model when primary fails
+GROWTH_OPENROUTER_MAX_TOKENS=1200
+# Set to 1 only if your model requires reasoning (most growth drafts should leave this off)
+GROWTH_OPENROUTER_REASONING=0
+# Optional: only call the LLM when composite fit >= this (default 42)
+GROWTH_MIN_DRAFT_SCORE=50
+# Log prompts + OpenRouter completions in the dev server terminal (on by default in npm run dev)
+GROWTH_AI_DEBUG=1
+```
+
+Harvest visits each leadβs public profile (when a profile URL is available) and stores a short **profile research** blurb on the lead. **Replies are not auto-generated on harvest** β open a lead in **Inbox**, expand it, and click **Generate reply** (OpenRouter + template fallback).
+
+Inbox and Leads support **Source** filters: All / X / LinkedIn.
+
+**Do not commit API keys.** If a key was pasted into chat or committed, rotate it in the OpenRouter dashboard.
+
+6. Playwright (local only):
+
+```bash
+npm install -D playwright
+npx playwright install chromium
+```
+
+7. **Production connect (no terminal)** β enable [Browser Rendering](https://developers.cloudflare.com/browser-run/) on your Cloudflare account. This repoβs `wrangler.jsonc` includes a `browser` binding. After deploy, **Connect** in admin uses Cloudflare-hosted Chromium in the embedded panel.
+
+ Optional fallback: deploy `deploy/growth-connect` to Fly/Render and set `GROWTH_CONNECT_VIEWER_BASE` on the Worker (admin proxies via `/api/admin/growth/connect/relay`).
+
+8. **Local dev** β Connect runs inside `npm run dev` (in-process Playwright). One-time setup:
+
+```bash
+npx playwright install chromium
+```
+
+Optional: `npm run growth:connect-server` is a legacy standalone helper. `GROWTH_CONNECT_USE_HOSTED_LOCAL=1` tries Miniflareβs browser binding under `next dev` (usually broken).
+
+## Daily workflow
+
+1. **Connect** β Settings β **Connect** (or connect page). Production uses Cloudflare Browser Rendering; local dev opens Chromium on your machine.
+2. **Harvest** β `/admin/growth` β **Harvest** (runs up to 4 ICP intent-phrase searches on X/LinkedIn, then scores and saves leads). Tune phrases in Settings β ICP JSON (`intentPhrases`). If LinkedIn search returns 0 posts, check the activity log for DOM counts; try `GROWTH_HARVEST_HEADED=1` in `.env.local` and reconnect LinkedIn.
+3. **Review** β **Inbox**: filter by **Source** (X / LinkedIn) and fit score, expand a lead, **Generate reply**, then approve / post / copy.
+4. **Pipeline** β **Leads**: filter by score, update pipeline status.
+5. **Tune scoring** β **Settings**: edit ICP JSON and connect platforms.
+
+If the UI shows **Session expired** or **Login or verification needed**, run `growth-connect` again for that platform.
+
+## Local connector CLI
+
+From the repo root, with `npm run dev` (or production `GROWTH_API_BASE`) running:
+
+```bash
+export GROWTH_CONNECTOR_SECRET='β¦'
+export GROWTH_API_BASE='http://localhost:3000' # optional
+
+# Headed login β upload cookies β health ping
+node scripts/growth-connect.mjs linkedin
+
+# Human-paced read-only harvest β ingest
+node scripts/growth-harvest.mjs linkedin
+node scripts/growth-harvest.mjs x --dry-run
+
+# Batch ingest from file
+node scripts/growth-connector.mjs push examples/growth-signals.example.json
+```
+
+Shorthand npm scripts:
+
+- `npm run growth:connect -- linkedin`
+- `npm run growth:harvest -- linkedin`
+- `npm run growth:push -- examples/growth-signals.example.json`
+- `npm run growth:cookies:upload -- linkedin ./cookies.json`
+- `npm run growth:cookies:pull -- linkedin -o ./session.json`
+
+Env toggles:
+
+- `GROWTH_IGNORE_BUSINESS_HOURS=1` β allow harvest outside 08:00β20:00
+- `GROWTH_HEADLESS=1` β headless harvest (less human-like; use only on a trusted VPS)
+
+### Posting replies (X)
+
+Drafts in the admin inbox are **not** X.com draft-folder items β they are review queue entries in your database.
+
+- **Post reply on X** (Inbox): uses your connected X session + Playwright locally (`npm run dev`) to open the source tweet and submit the reply. Tries the inline reply box, the Reply button, then `x.com/intent/tweet?in_reply_to=β¦` as fallback.
+- Debug posting UI: `GROWTH_PUBLISH_HEADED=1` opens a visible Chromium window while posting.
+- Replies must be **β€ 280 characters**.
+- Optional auto-post after harvest/capture (no review):
+
+```bash
+GROWTH_AUTO_PUBLISH_X=1
+```
+
+Use with care β posts go live immediately when a tweet URL is present.
+
+LinkedIn auto-post is not supported yet; use **Copy** and post manually.
+
+## HTTP API
+
+Auth: admin session cookie **or** `Authorization: Bearer $GROWTH_CONNECTOR_SECRET`
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `/api/admin/growth/signals` | POST | Single signal ingest |
+| `/api/admin/growth/connector/ingest` | POST | Batch `{ "signals": [ ... ] }` |
+| `/api/admin/growth/credentials` | POST | Upload encrypted cookies |
+| `/api/admin/growth/sessions` | GET | **Admin** β platform session status |
+| `/api/admin/growth/sessions/health` | POST | **Connector** β report active/expired/challenge |
+| `/api/admin/growth/connector/credentials?platform=` | GET | **Connector secret only** β decrypt for local automation |
+| `/api/admin/growth/inbox` | GET | Drafts + leads (`?minScore=60`) |
+| `/api/admin/growth/leads` | GET | Leads list (`?minScore=&maxScore=`) |
+
+## Data model
+
+- `growth_signals` β source post / comment
+- `growth_leads` β person + scores + pipeline
+- `growth_drafts` β reply/post text + approval state
+- `growth_icp_settings` β scoring config
+- `growth_platform_credentials` β encrypted cookies + `session_status`, `last_health_check_at`, `last_error`, `connected_account_label`
+
+## Roadmap
+
+- Regenerate draft from inbox with fresh profile pull
+- Publish worker on VPS: post only **approved** drafts, rate-limited
+- systemd timer for `growth-harvest` on a dedicated connector VM
diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md
new file mode 100644
index 0000000..64839e5
--- /dev/null
+++ b/docs/PAYMENTS.md
@@ -0,0 +1,81 @@
+# Payments β legal, receipts, and customer flow
+
+## Customer payment pages
+
+Public routes under `/pay/[slug]`:
+
+| Route | Purpose |
+| --- | --- |
+| `/pay/[slug]` | Invoice + pay (card via Paystack when configured, Mobile Money via KPay) |
+| `/pay/[slug]/return` | Paystack and KPay return handler |
+| `/pay/[slug]/receipt` | **Paid only** β printable receipt (A4, 12pt body, 20mm margins) |
+| `/pay/legal` | Payment processing & data notice |
+
+Every pay page includes a **PaymentLegalNotice** footer linking to `/pay/legal`.
+
+## Payment processor
+
+- **Paystack** is the primary card checkout when `PAYSTACK_SECRET_KEY` is set. Cards are charged in the invoice's local currency on Paystack's hosted page.
+- **KPay** still handles Mobile Money. If Paystack is not configured, KPay also handles cards (including the existing USD card limit).
+- Stackwise stores invoice metadata and payment status in Neon Postgres.
+- Card numbers and mobile-money PINs are never stored by Stackwise.
+- New invoices are tagged test or live from the Paystack key (`sk_test_` / `sk_live_`) when Paystack is configured, and from the KPay key otherwise.
+
+## Audit logging
+
+Customer interactions on pay pages log IP address, user agent, and optional `ip_geo` (country, city, ISP, ASN, coordinates, proxy/hosting flags) to `payment_audit_logs`. Geo is taken from Cloudflare request headers in production when present; otherwise a public-IP lookup via [ipwho.is](https://ipwho.is) runs at log time.
+
+| Event | Trigger |
+| --- | --- |
+| `PAY_PAGE_VIEW` | Invoice page load |
+| `PAYMENT_INIT` | Pay button / Paystack or KPay redirect |
+| `RETURN_CALLBACK` | Paystack or KPay return URL |
+| `RECEIPT_VIEW` | Receipt page (paid only) |
+
+Logs are visible at `/admin/audit-logs` (latest 1000 events) and on each payment-link detail page. Run `npm run db:migrate` (includes `ip_geo` via migration `0008_payment_audit_ip_geo.sql`).
+
+## Receipt access
+
+Receipts are gated by payment status:
+
+- `canAccessReceipt(link)` returns true only when `status === "PAID"`.
+- Unpaid links redirect from `/pay/[slug]/receipt` to the pay page.
+- Admin βView receiptβ is shown only for paid links.
+
+## Receipt email
+
+When payment completes (webhook or return URL sync), `finalizePaidPayment`:
+
+1. Marks the link `PAID`
+2. Sends receipt email to the customer via `sendPaymentReceiptEmails`
+3. Notifies admins listed in `ADMIN_NOTIFY_EMAILS`
+
+The email includes a **receipt URL** (`/pay/[slug]/receipt`) for viewing and printing.
+
+## Admin dashboard
+
+- Live links: `kpay_is_test = false` only (production DB filter)
+- Tables support **search** (invoice, customer, email) and **status filter**
+- **10 rows per page** pagination
+
+## Environment variables
+
+See `.env.example` for `PAYSTACK_SECRET_KEY`, `KPAY_*`, `DATABASE_URL`, `SMTP_*`, and `ADMIN_EMAILS`.
+
+Paystack dashboard (Settings β API Keys & Webhooks):
+
+| Field | Value |
+| --- | --- |
+| **Webhook URL** (test and live) | `https://stackwisetechnologies.com/api/webhooks/paystack` |
+| **Callback URL** (test and live) | Leave blank, or `https://stackwisetechnologies.com/pay` β **not** the webhook path. Customers return to `/pay/{slug}/return` because each `transaction/initialize` sets `callback_url`. |
+
+Env: `PAYSTACK_SECRET_KEY` only (`sk_test_` / `sk_live_`). The public key is unused in the hosted-checkout flow.
+
+Webhooks: listen for `charge.success`, `charge.failed`, `subscription.create`, and `subscription.disable`. The secret key signs payloads (`x-paystack-signature`); no separate webhook secret.
+
+### Maintenance subscriptions (Paystack)
+
+- Admin β **Subscriptions** creates a Paystack plan and customer link `/pay/subscribe/[slug]`.
+- Customer authorizes recurring card billing (monthly / quarterly / annually).
+- Invoice currency must match Paystack (`PAYSTACK_CURRENCIES`, default `KES`).
+- Renewals and bank payouts are managed in the Paystack dashboard.
diff --git a/docs/images/logo.svg b/docs/images/logo.svg
new file mode 100644
index 0000000..de42381
--- /dev/null
+++ b/docs/images/logo.svg
@@ -0,0 +1,27 @@
+
diff --git a/docs/images/mark.svg b/docs/images/mark.svg
new file mode 100644
index 0000000..1775c77
--- /dev/null
+++ b/docs/images/mark.svg
@@ -0,0 +1,16 @@
+
diff --git a/docs/images/readme-about.jpg b/docs/images/readme-about.jpg
new file mode 100644
index 0000000..e038824
Binary files /dev/null and b/docs/images/readme-about.jpg differ
diff --git a/docs/images/readme-payments.jpg b/docs/images/readme-payments.jpg
new file mode 100644
index 0000000..4b82432
Binary files /dev/null and b/docs/images/readme-payments.jpg differ
diff --git a/docs/images/readme-stack.jpg b/docs/images/readme-stack.jpg
new file mode 100644
index 0000000..cd7aed1
Binary files /dev/null and b/docs/images/readme-stack.jpg differ
diff --git a/examples/growth-signals.example.json b/examples/growth-signals.example.json
new file mode 100644
index 0000000..76548aa
--- /dev/null
+++ b/examples/growth-signals.example.json
@@ -0,0 +1,18 @@
+{
+ "signals": [
+ {
+ "platform": "linkedin",
+ "sourceUrl": "https://www.linkedin.com/posts/example",
+ "authorName": "Jane Doe",
+ "roleTitle": "Founder",
+ "bodyText": "We shipped an AI MVP fast but need help hardening it for production and payments."
+ },
+ {
+ "platform": "x",
+ "sourceUrl": "https://x.com/example/status/1",
+ "authorName": "Alex Kim",
+ "roleTitle": "CEO",
+ "bodyText": "Looking for a senior engineering partner to own our backend after the vibe-coded launch."
+ }
+ ]
+}
diff --git a/migrations/0001_neon_init.sql b/migrations/0001_neon_init.sql
new file mode 100644
index 0000000..f811ed3
--- /dev/null
+++ b/migrations/0001_neon_init.sql
@@ -0,0 +1,49 @@
+CREATE TABLE IF NOT EXISTS admin_otps (
+ id UUID PRIMARY KEY,
+ subject_hash VARCHAR(64) NOT NULL,
+ otp VARCHAR(6) NOT NULL,
+ expires_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_admin_otps_subject ON admin_otps(subject_hash);
+
+CREATE TABLE IF NOT EXISTS otp_verify_attempts (
+ subject_hash VARCHAR(64) PRIMARY KEY,
+ failed_count INT NOT NULL DEFAULT 0,
+ locked_until TIMESTAMPTZ NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS otp_send_throttle (
+ subject_hash VARCHAR(64) PRIMARY KEY,
+ last_attempt_at TIMESTAMPTZ NULL,
+ last_sent_at TIMESTAMPTZ NULL
+);
+
+CREATE TABLE IF NOT EXISTS payment_links (
+ id UUID PRIMARY KEY,
+ slug VARCHAR(32) UNIQUE NOT NULL,
+ customer_name TEXT NOT NULL,
+ customer_email TEXT NOT NULL,
+ currency VARCHAR(8) NOT NULL DEFAULT 'XAF',
+ amount_usd NUMERIC(12, 2) NOT NULL,
+ amount_local NUMERIC(14, 2) NOT NULL,
+ exchange_rate NUMERIC(16, 8) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+ line_items JSONB NOT NULL,
+ notes TEXT,
+ kpay_payment_id TEXT,
+ kpay_reference TEXT,
+ gateway_url TEXT,
+ invoice_number VARCHAR(32) UNIQUE NOT NULL,
+ sent_at TIMESTAMPTZ,
+ paid_at TIMESTAMPTZ,
+ receipt_sent_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_payment_links_slug ON payment_links(slug);
+CREATE INDEX IF NOT EXISTS idx_payment_links_status ON payment_links(status);
+CREATE INDEX IF NOT EXISTS idx_payment_links_created ON payment_links(created_at DESC);
diff --git a/migrations/0002_kpay_is_test.sql b/migrations/0002_kpay_is_test.sql
new file mode 100644
index 0000000..5b3846a
--- /dev/null
+++ b/migrations/0002_kpay_is_test.sql
@@ -0,0 +1 @@
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS kpay_is_test BOOLEAN;
diff --git a/migrations/0003_payment_audit_logs.sql b/migrations/0003_payment_audit_logs.sql
new file mode 100644
index 0000000..44368ae
--- /dev/null
+++ b/migrations/0003_payment_audit_logs.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS payment_audit_logs (
+ id UUID PRIMARY KEY,
+ payment_link_id UUID NOT NULL REFERENCES payment_links(id) ON DELETE CASCADE,
+ event VARCHAR(32) NOT NULL,
+ ip_address VARCHAR(45),
+ user_agent TEXT,
+ metadata JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_payment_audit_link ON payment_audit_logs(payment_link_id, created_at DESC);
diff --git a/migrations/0004_allowed_payment_methods.sql b/migrations/0004_allowed_payment_methods.sql
new file mode 100644
index 0000000..27072b7
--- /dev/null
+++ b/migrations/0004_allowed_payment_methods.sql
@@ -0,0 +1 @@
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS allowed_payment_methods VARCHAR(20) NOT NULL DEFAULT 'BOTH';
diff --git a/migrations/0005_manual_payment_fields.sql b/migrations/0005_manual_payment_fields.sql
new file mode 100644
index 0000000..709c616
--- /dev/null
+++ b/migrations/0005_manual_payment_fields.sql
@@ -0,0 +1,7 @@
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_source VARCHAR(20);
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_method VARCHAR(32);
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS amount_received_usd NUMERIC(12, 2);
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS amount_received_local NUMERIC(14, 2);
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS collected_at TIMESTAMPTZ;
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_reference TEXT;
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_notes TEXT;
diff --git a/migrations/0006_paystack_reference.sql b/migrations/0006_paystack_reference.sql
new file mode 100644
index 0000000..7ddd5bb
--- /dev/null
+++ b/migrations/0006_paystack_reference.sql
@@ -0,0 +1,4 @@
+ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS paystack_reference TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_payment_links_paystack_reference
+ ON payment_links(paystack_reference);
diff --git a/migrations/0007_maintenance_subscriptions.sql b/migrations/0007_maintenance_subscriptions.sql
new file mode 100644
index 0000000..5d92ceb
--- /dev/null
+++ b/migrations/0007_maintenance_subscriptions.sql
@@ -0,0 +1,42 @@
+CREATE TABLE IF NOT EXISTS maintenance_subscriptions (
+ id UUID PRIMARY KEY,
+ slug VARCHAR(32) UNIQUE NOT NULL,
+ customer_name TEXT NOT NULL,
+ customer_email TEXT NOT NULL,
+ currency VARCHAR(8) NOT NULL DEFAULT 'KES',
+ amount_minor INTEGER NOT NULL,
+ amount_usd NUMERIC(12, 2),
+ exchange_rate NUMERIC(16, 8),
+ interval VARCHAR(20) NOT NULL,
+ description TEXT NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+ paystack_plan_code TEXT,
+ paystack_subscription_code TEXT,
+ paystack_customer_code TEXT,
+ paystack_reference TEXT,
+ gateway_url TEXT,
+ sent_at TIMESTAMPTZ,
+ activated_at TIMESTAMPTZ,
+ cancelled_at TIMESTAMPTZ,
+ next_payment_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_maintenance_subscriptions_slug
+ ON maintenance_subscriptions(slug);
+CREATE INDEX IF NOT EXISTS idx_maintenance_subscriptions_status
+ ON maintenance_subscriptions(status);
+
+CREATE TABLE IF NOT EXISTS subscription_charge_events (
+ id UUID PRIMARY KEY,
+ subscription_id UUID NOT NULL REFERENCES maintenance_subscriptions(id) ON DELETE CASCADE,
+ paystack_reference TEXT NOT NULL,
+ amount_minor INTEGER NOT NULL,
+ currency VARCHAR(8) NOT NULL,
+ paid_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_subscription_charge_reference
+ ON subscription_charge_events(paystack_reference);
diff --git a/migrations/0008_payment_audit_ip_geo.sql b/migrations/0008_payment_audit_ip_geo.sql
new file mode 100644
index 0000000..442a24e
--- /dev/null
+++ b/migrations/0008_payment_audit_ip_geo.sql
@@ -0,0 +1 @@
+ALTER TABLE payment_audit_logs ADD COLUMN IF NOT EXISTS ip_geo JSONB;
diff --git a/migrations/0009_growth_engine.sql b/migrations/0009_growth_engine.sql
new file mode 100644
index 0000000..824d0aa
--- /dev/null
+++ b/migrations/0009_growth_engine.sql
@@ -0,0 +1,72 @@
+CREATE TABLE IF NOT EXISTS growth_icp_settings (
+ id VARCHAR(32) PRIMARY KEY DEFAULT 'default',
+ config JSONB NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS growth_platform_credentials (
+ platform VARCHAR(16) PRIMARY KEY,
+ cookie_ciphertext TEXT NOT NULL,
+ cookie_iv TEXT NOT NULL,
+ user_agent TEXT,
+ display_label TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_sync_at TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS growth_signals (
+ id UUID PRIMARY KEY,
+ platform VARCHAR(16) NOT NULL,
+ source_url TEXT,
+ author_name TEXT,
+ author_handle TEXT,
+ author_profile_url TEXT,
+ role_title TEXT,
+ company_name TEXT,
+ body_text TEXT NOT NULL,
+ context_summary TEXT,
+ raw_payload JSONB,
+ ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_growth_signals_ingested ON growth_signals (ingested_at DESC);
+
+CREATE TABLE IF NOT EXISTS growth_leads (
+ id UUID PRIMARY KEY,
+ signal_id UUID REFERENCES growth_signals(id) ON DELETE SET NULL,
+ full_name TEXT NOT NULL,
+ role_title TEXT,
+ company_name TEXT,
+ profile_url TEXT,
+ email TEXT,
+ platform VARCHAR(16),
+ icp_score SMALLINT NOT NULL DEFAULT 0,
+ intent_score SMALLINT NOT NULL DEFAULT 0,
+ composite_score SMALLINT NOT NULL DEFAULT 0,
+ score_reasons JSONB NOT NULL DEFAULT '[]'::jsonb,
+ pipeline_status VARCHAR(32) NOT NULL DEFAULT 'new',
+ feedback_note TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_growth_leads_composite ON growth_leads (composite_score DESC);
+CREATE INDEX IF NOT EXISTS idx_growth_leads_status ON growth_leads (pipeline_status);
+
+CREATE TABLE IF NOT EXISTS growth_drafts (
+ id UUID PRIMARY KEY,
+ lead_id UUID REFERENCES growth_leads(id) ON DELETE CASCADE,
+ signal_id UUID REFERENCES growth_signals(id) ON DELETE SET NULL,
+ platform VARCHAR(16) NOT NULL,
+ draft_kind VARCHAR(16) NOT NULL DEFAULT 'reply',
+ body_text TEXT NOT NULL,
+ image_notes TEXT,
+ status VARCHAR(24) NOT NULL DEFAULT 'pending_review',
+ review_notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ published_at TIMESTAMPTZ,
+ publish_error TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_growth_drafts_status ON growth_drafts (status);
diff --git a/migrations/0010_growth_session_health.sql b/migrations/0010_growth_session_health.sql
new file mode 100644
index 0000000..96d1eb3
--- /dev/null
+++ b/migrations/0010_growth_session_health.sql
@@ -0,0 +1,11 @@
+ALTER TABLE growth_platform_credentials
+ ADD COLUMN IF NOT EXISTS session_status VARCHAR(24) NOT NULL DEFAULT 'unknown';
+
+ALTER TABLE growth_platform_credentials
+ ADD COLUMN IF NOT EXISTS last_health_check_at TIMESTAMPTZ;
+
+ALTER TABLE growth_platform_credentials
+ ADD COLUMN IF NOT EXISTS last_error TEXT;
+
+ALTER TABLE growth_platform_credentials
+ ADD COLUMN IF NOT EXISTS connected_account_label TEXT;
diff --git a/migrations/0011_growth_connect_browser_sessions.sql b/migrations/0011_growth_connect_browser_sessions.sql
new file mode 100644
index 0000000..a9eb60f
--- /dev/null
+++ b/migrations/0011_growth_connect_browser_sessions.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS growth_connect_browser_sessions (
+ id UUID PRIMARY KEY,
+ platform VARCHAR(16) NOT NULL,
+ puppeteer_session_id TEXT NOT NULL,
+ status VARCHAR(24) NOT NULL DEFAULT 'login',
+ last_error TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_growth_connect_browser_status
+ ON growth_connect_browser_sessions (status, updated_at DESC);
diff --git a/migrations/0012_growth_lead_profile.sql b/migrations/0012_growth_lead_profile.sql
new file mode 100644
index 0000000..7090ec2
--- /dev/null
+++ b/migrations/0012_growth_lead_profile.sql
@@ -0,0 +1,2 @@
+ALTER TABLE growth_leads
+ ADD COLUMN IF NOT EXISTS profile_summary TEXT;
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..07d948b 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "www.gravatar.com",
+ pathname: "/avatar/**",
+ },
+ ],
+ },
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 131e365..405cf84 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,14 +1,17 @@
{
"name": "stackwise-technologies-limited",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stackwise-technologies-limited",
- "version": "0.1.0",
+ "version": "0.2.0",
"dependencies": {
+ "@cloudflare/puppeteer": "^1.4.0",
+ "@neondatabase/serverless": "^1.0.2",
"next": "16.2.6",
+ "nodemailer": "^7.0.6",
"react": "19.2.4",
"react-dom": "19.2.4"
},
@@ -16,10 +19,12 @@
"@opennextjs/cloudflare": "^1.20.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
+ "@types/nodemailer": "^7.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
+ "playwright": "^1.49.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.11",
@@ -1451,6 +1456,21 @@
"node": ">=22.0.0"
}
},
+ "node_modules/@cloudflare/puppeteer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/puppeteer/-/puppeteer-1.4.0.tgz",
+ "integrity": "sha512-39lo96Y7ErOJFmu/KIJU1VLaOibhynx/BauKCZOW5+RVH/mG0jZrAEGJlqCdx5ZxLHyWppm78YX3jLNPCBB+fA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "2.2.4",
+ "debug": "^4.3.5",
+ "devtools-protocol": "0.0.1299070",
+ "ws": "^8.18.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@cloudflare/unenv-preset": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
@@ -2970,6 +2990,15 @@
"@emnapi/runtime": "^1.7.1"
}
},
+ "node_modules/@neondatabase/serverless": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz",
+ "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=19.0.0"
+ }
+ },
"node_modules/@next/env": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
@@ -3465,6 +3494,118 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@puppeteer/browsers": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.4.tgz",
+ "integrity": "sha512-BdG2qiI1dn89OTUUsx2GZSpUzW+DRffR1wlMJyKxVHYrhnKoELSDxDd+2XImUkuWPEKk76H5FcM/gPFrEK1Tfw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "extract-zip": "^2.0.1",
+ "progress": "^2.0.3",
+ "proxy-agent": "^6.4.0",
+ "semver": "^7.6.2",
+ "tar-fs": "^3.0.6",
+ "unbzip2-stream": "^1.4.3",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "browsers": "lib/cjs/main-cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/@puppeteer/browsers/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/@puppeteer/browsers/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
@@ -4562,6 +4703,12 @@
"tailwindcss": "4.3.3"
}
},
+ "node_modules/@tootallnate/quickjs-emscripten": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
+ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
+ "license": "MIT"
+ },
"node_modules/@tsconfig/node18": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-1.0.3.tgz",
@@ -4623,7 +4770,7 @@
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -4640,6 +4787,16 @@
"form-data": "^4.0.4"
}
},
+ "node_modules/@types/nodemailer": {
+ "version": "7.0.12",
+ "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.12.tgz",
+ "integrity": "sha512-80vKwiIsVSyFA1rRovH59jNPLBOuc6dRZIHEu40gXTkBkZnQv8vog1xSGEb9j5q/tdMAs5ivvDR2pLTU0hGHXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -4660,6 +4817,16 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
@@ -5442,6 +5609,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
@@ -5486,7 +5662,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5496,7 +5671,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -5702,6 +5876,18 @@
"node": ">=12"
}
},
+ "node_modules/ast-types": {
+ "version": "0.13.4",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
+ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -5769,6 +5955,20 @@
"node": ">= 0.4"
}
},
+ "node_modules/b4a": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz",
+ "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native-b4a": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-b4a": {
+ "optional": true
+ }
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -5776,6 +5976,106 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/bare-events": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
+ "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "bare-abort-controller": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-abort-controller": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-fs": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz",
+ "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-events": "^2.5.4",
+ "bare-path": "^3.0.0",
+ "bare-stream": "^2.6.4",
+ "bare-url": "^2.2.2",
+ "fast-fifo": "^1.3.2"
+ },
+ "engines": {
+ "bare": ">=1.28.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-path": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
+ "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/bare-stream": {
+ "version": "2.13.4",
+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
+ "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.8.1",
+ "streamx": "^2.25.0",
+ "teex": "^1.0.1"
+ },
+ "peerDependencies": {
+ "bare-abort-controller": "*",
+ "bare-buffer": "*",
+ "bare-events": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-abort-controller": {
+ "optional": true
+ },
+ "bare-buffer": {
+ "optional": true
+ },
+ "bare-events": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-url": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.5.tgz",
+ "integrity": "sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-path": "^3.0.0"
+ }
+ },
+ "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/baseline-browser-mapping": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz",
@@ -5788,6 +6088,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/basic-ftp": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
+ "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/blake3-wasm": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
@@ -5899,6 +6208,39 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "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/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -6151,7 +6493,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -6164,7 +6505,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -6295,6 +6635,15 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "node_modules/data-uri-to-buffer": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
+ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -6353,7 +6702,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -6410,6 +6758,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/degenerator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
+ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ast-types": "^0.13.4",
+ "escodegen": "^2.1.0",
+ "esprima": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -6440,6 +6802,12 @@
"node": ">=8"
}
},
+ "node_modules/devtools-protocol": {
+ "version": "0.0.1299070",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1299070.tgz",
+ "integrity": "sha512-+qtL3eX50qsJ7c+qVyagqi7AWMoQCBGNfoyJZMwm/NSXVqLYbuitrWEEIzxfUmTNy7//Xe8yhMmQ+elj3uAqSg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -6537,6 +6905,15 @@
"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/enhanced-resolve": {
"version": "5.24.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz",
@@ -6826,7 +7203,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6852,6 +7228,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/escodegen": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esprima": "^4.0.1",
+ "estraverse": "^5.2.0",
+ "esutils": "^2.0.2"
+ },
+ "bin": {
+ "escodegen": "bin/escodegen.js",
+ "esgenerate": "bin/esgenerate.js"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "optionalDependencies": {
+ "source-map": "~0.6.1"
+ }
+ },
"node_modules/eslint": {
"version": "9.39.5",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
@@ -7216,7 +7613,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
@@ -7256,7 +7652,6 @@
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
@@ -7276,7 +7671,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
@@ -7302,6 +7696,15 @@
"node": ">=6"
}
},
+ "node_modules/events-universal": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
+ "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-events": "^2.7.0"
+ }
+ },
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -7390,6 +7793,41 @@
"node": ">= 0.6"
}
},
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
+ "node_modules/extract-zip/node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -7397,6 +7835,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-fifo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+ "license": "MIT"
+ },
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -7451,6 +7895,15 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -7772,7 +8225,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -7874,6 +8326,20 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/get-uri": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
+ "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-ftp": "^5.0.2",
+ "data-uri-to-buffer": "^6.0.2",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/glob": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz",
@@ -8148,6 +8614,32 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -8185,6 +8677,26 @@
"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/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -8244,6 +8756,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ip-address": {
+ "version": "10.7.4",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
+ "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -8454,6 +8975,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -9931,7 +10461,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -9985,6 +10514,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/netmask": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",
+ "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/next": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
@@ -10137,6 +10675,15 @@
"node": ">=18"
}
},
+ "node_modules/nodemailer": {
+ "version": "7.0.13",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
+ "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -10321,7 +10868,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -10412,6 +10958,38 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pac-proxy-agent": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
+ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tootallnate/quickjs-emscripten": "^0.23.0",
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "get-uri": "^6.0.1",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.6",
+ "pac-resolver": "^7.0.1",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/pac-resolver": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
+ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
+ "license": "MIT",
+ "dependencies": {
+ "degenerator": "^5.0.0",
+ "netmask": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -10510,6 +11088,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -10529,6 +11113,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
+ "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.64.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
+ "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -10578,6 +11191,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -10604,6 +11226,50 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-agent": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
+ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "http-proxy-agent": "^7.0.1",
+ "https-proxy-agent": "^7.0.6",
+ "lru-cache": "^7.14.1",
+ "pac-proxy-agent": "^7.1.0",
+ "proxy-from-env": "^1.1.0",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/proxy-agent/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "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/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -10754,6 +11420,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -11247,11 +11922,49 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.10",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
+ "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
+ "devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -11322,6 +12035,17 @@
"node": ">= 0.4"
}
},
+ "node_modules/streamx": {
+ "version": "2.28.1",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
+ "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
+ "license": "MIT",
+ "dependencies": {
+ "events-universal": "^1.0.0",
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
+ }
+ },
"node_modules/string-width": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -11486,7 +12210,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -11598,6 +12321,41 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tar-fs": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz",
+ "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0",
+ "tar-stream": "^3.1.5"
+ },
+ "optionalDependencies": {
+ "bare-fs": "^4.0.1",
+ "bare-path": "^3.0.0"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz",
+ "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==",
+ "license": "MIT",
+ "dependencies": {
+ "b4a": "^1.9.0",
+ "bare-fs": "^4.8.2",
+ "fast-fifo": "^1.3.2",
+ "streamx": "^2.28.1"
+ }
+ },
+ "node_modules/teex": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
+ "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
+ "license": "MIT",
+ "dependencies": {
+ "streamx": "^2.12.5"
+ }
+ },
"node_modules/terser": {
"version": "5.16.9",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.16.9.tgz",
@@ -11624,6 +12382,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/text-decoder": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
+ "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
+ "node_modules/through": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
+ "license": "MIT"
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -11944,6 +12717,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/unbzip2-stream": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
+ "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.2.1",
+ "through": "^2.3.8"
+ }
+ },
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
@@ -11958,7 +12741,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/unenv": {
@@ -13305,14 +14088,12 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -13334,7 +14115,6 @@
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
@@ -13391,6 +14171,16 @@
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index b642739..2327483 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"version": "0.2.0",
"private": true,
"scripts": {
+ "db:migrate": "node scripts/migrate.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
@@ -13,11 +14,23 @@
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"deploy:live": "bash scripts/deploy-live.sh",
+ "cf:secrets": "node scripts/sync-cloudflare-env.mjs .env.prod",
+ "db:migrate:prod": "MIGRATE_ENV=prod node scripts/migrate.mjs",
+ "db:reset:prod-payments": "MIGRATE_ENV=prod node scripts/db-reset-prod-payments.mjs",
"cf:whoami": "wrangler whoami",
- "cf:status": "wrangler deployments status --name stackwise-technologies"
+ "cf:status": "wrangler deployments status --name stackwise-technologies",
+ "growth:push": "node scripts/growth-connector.mjs push",
+ "growth:cookies:upload": "node scripts/growth-connector.mjs upload-cookies",
+ "growth:cookies:pull": "node scripts/growth-connector.mjs pull-cookies",
+ "growth:connect": "node scripts/growth-connect.mjs",
+ "growth:harvest": "node scripts/growth-harvest.mjs",
+ "growth:connect-server": "node scripts/growth-connect-server.mjs"
},
"dependencies": {
+ "@cloudflare/puppeteer": "^1.4.0",
+ "@neondatabase/serverless": "^1.0.2",
"next": "16.2.6",
+ "nodemailer": "^7.0.6",
"react": "19.2.4",
"react-dom": "19.2.4"
},
@@ -25,13 +38,15 @@
"@opennextjs/cloudflare": "^1.20.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
+ "@types/nodemailer": "^7.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
+ "playwright": "^1.49.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.11",
"wrangler": "^4.114.0"
}
-}
\ No newline at end of file
+}
diff --git a/public/images/portfolio/alem-technoshop.png b/public/images/portfolio/alem-technoshop.png
new file mode 100644
index 0000000..df74b7d
Binary files /dev/null and b/public/images/portfolio/alem-technoshop.png differ
diff --git a/public/images/portfolio/alemnet.png b/public/images/portfolio/alemnet.png
new file mode 100644
index 0000000..d1c44b7
Binary files /dev/null and b/public/images/portfolio/alemnet.png differ
diff --git a/public/images/portfolio/open.png b/public/images/portfolio/open.png
new file mode 100644
index 0000000..c3bbbda
Binary files /dev/null and b/public/images/portfolio/open.png differ
diff --git a/public/images/portfolio/temps.png b/public/images/portfolio/temps.png
new file mode 100644
index 0000000..b3df951
Binary files /dev/null and b/public/images/portfolio/temps.png differ
diff --git a/scripts/db-reset-prod-payments.mjs b/scripts/db-reset-prod-payments.mjs
new file mode 100644
index 0000000..40a61d9
--- /dev/null
+++ b/scripts/db-reset-prod-payments.mjs
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+/**
+ * Remove sandbox/test payment links from production Neon and mark remaining drafts as live.
+ *
+ * Usage:
+ * node scripts/db-reset-prod-payments.mjs # dry run (default)
+ * node scripts/db-reset-prod-payments.mjs --confirm # apply changes
+ *
+ * Requires DATABASE_URL in .env.prod (MIGRATE_ENV=prod).
+ */
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+import { neon } from "@neondatabase/serverless";
+
+function loadEnvFile(filename, { override = false } = {}) {
+ const envPath = path.join(process.cwd(), filename);
+ if (!fs.existsSync(envPath)) return;
+ for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) continue;
+ const eq = trimmed.indexOf("=");
+ if (eq === -1) continue;
+ const key = trimmed.slice(0, eq).trim();
+ const value = trimmed.slice(eq + 1).trim();
+ if (override || !process.env[key]) process.env[key] = value;
+ }
+}
+
+loadEnvFile(".env.prod", { override: true });
+
+const confirm = process.argv.includes("--confirm");
+const url = process.env.DATABASE_URL;
+
+if (!url) {
+ console.error("DATABASE_URL is not set (.env.prod)");
+ process.exit(1);
+}
+
+const sql = neon(url);
+
+const flaggedTest = await sql`
+ SELECT id, invoice_number, status, kpay_is_test
+ FROM payment_links
+ WHERE kpay_is_test = true
+ ORDER BY created_at DESC
+`;
+
+const legacySandbox = await sql`
+ SELECT id, invoice_number, status, kpay_is_test
+ FROM payment_links
+ WHERE kpay_is_test IS NULL AND kpay_payment_id IS NOT NULL
+ ORDER BY created_at DESC
+`;
+
+const draftsToMarkLive = await sql`
+ SELECT id, invoice_number, status, kpay_is_test
+ FROM payment_links
+ WHERE kpay_is_test IS NULL AND kpay_payment_id IS NULL
+ ORDER BY created_at DESC
+`;
+
+const toDelete = [...flaggedTest, ...legacySandbox];
+
+console.log("Production payment link cleanup");
+console.log("================================");
+console.log(`Mode: ${confirm ? "APPLY" : "DRY RUN (pass --confirm to apply)"}`);
+console.log("");
+console.log(`Delete (test / legacy sandbox): ${toDelete.length}`);
+for (const row of toDelete) {
+ console.log(` - ${row.invoice_number} (${row.status}, kpay_is_test=${row.kpay_is_test})`);
+}
+console.log("");
+console.log(`Mark as live (drafts with no KPay id): ${draftsToMarkLive.length}`);
+for (const row of draftsToMarkLive) {
+ console.log(` - ${row.invoice_number} (${row.status}) β kpay_is_test = false`);
+}
+
+if (!confirm) {
+ console.log("");
+ console.log("No changes made. Re-run with --confirm to apply.");
+ process.exit(0);
+}
+
+if (toDelete.length > 0) {
+ const ids = toDelete.map((row) => row.id);
+ await sql`
+ DELETE FROM payment_links
+ WHERE id = ANY(${ids}::uuid[])
+ `;
+ console.log(`Deleted ${toDelete.length} payment link(s).`);
+}
+
+if (draftsToMarkLive.length > 0) {
+ const draftIds = draftsToMarkLive.map((row) => row.id);
+ await sql`
+ UPDATE payment_links
+ SET kpay_is_test = false, updated_at = NOW()
+ WHERE id = ANY(${draftIds}::uuid[])
+ `;
+ console.log(`Marked ${draftsToMarkLive.length} draft link(s) as live.`);
+}
+
+const remaining = await sql`
+ SELECT
+ COUNT(*) FILTER (WHERE kpay_is_test = false) AS live_count,
+ COUNT(*) FILTER (WHERE kpay_is_test = true) AS test_count,
+ COUNT(*) FILTER (WHERE kpay_is_test IS NULL) AS unset_count
+ FROM payment_links
+`;
+
+console.log("");
+console.log("Remaining rows:", remaining[0]);
+console.log("Done.");
diff --git a/scripts/deploy-live.sh b/scripts/deploy-live.sh
index 0d904ef..f28774f 100755
--- a/scripts/deploy-live.sh
+++ b/scripts/deploy-live.sh
@@ -38,6 +38,32 @@ echo " tag: ${TAG}"
echo " message: ${MESSAGE}"
echo
+ENV_PROD="${ROOT}/.env.prod"
+PROD_LOCAL="${ROOT}/.env.production.local"
+PROD_LOCAL_BACKUP=""
+if [[ -f "$ENV_PROD" ]]; then
+ echo "==> Applying .env.prod for Next.js production build"
+ if [[ -f "$PROD_LOCAL" ]]; then
+ PROD_LOCAL_BACKUP="${PROD_LOCAL}.deploy-backup"
+ cp "$PROD_LOCAL" "$PROD_LOCAL_BACKUP"
+ fi
+ cp "$ENV_PROD" "$PROD_LOCAL"
+else
+ echo "warning: .env.prod not found β NEXT_PUBLIC_* may default to localhost" >&2
+fi
+
+cleanup_prod_local() {
+ if [[ -n "$PROD_LOCAL_BACKUP" && -f "$PROD_LOCAL_BACKUP" ]]; then
+ mv "$PROD_LOCAL_BACKUP" "$PROD_LOCAL"
+ elif [[ -f "$PROD_LOCAL" && -f "$ENV_PROD" ]]; then
+ rm -f "$PROD_LOCAL"
+ fi
+}
+trap cleanup_prod_local EXIT
+
+echo "==> Syncing Cloudflare Worker secrets from .env.prod"
+node scripts/sync-cloudflare-env.mjs .env.prod
+
echo "==> Building OpenNext Cloudflare worker"
npm run build:worker
diff --git a/scripts/growth-browser-lib.mjs b/scripts/growth-browser-lib.mjs
new file mode 100644
index 0000000..c21c1fa
--- /dev/null
+++ b/scripts/growth-browser-lib.mjs
@@ -0,0 +1,190 @@
+/**
+ * Shared helpers for local Playwright connector (runs on your Mac/VPS β not on Cloudflare Workers).
+ */
+
+import fs from "node:fs";
+
+export const base = process.env.GROWTH_API_BASE ?? "http://localhost:3000";
+
+export function authHeaders() {
+ const secret = process.env.GROWTH_CONNECTOR_SECRET;
+ if (!secret || secret.length < 32) {
+ throw new Error("Set GROWTH_CONNECTOR_SECRET (32+ characters) in the environment.");
+ }
+ return {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${secret}`,
+ };
+}
+
+export function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/** Random pause like a human reading or moving the pointer. */
+export async function humanDelay(minMs = 800, maxMs = 2800) {
+ const span = Math.max(0, maxMs - minMs);
+ const ms = minMs + Math.floor(Math.random() * (span + 1));
+ await sleep(ms);
+}
+
+export async function loadPlaywright() {
+ try {
+ const { chromium } = await import("playwright");
+ return chromium;
+ } catch {
+ throw new Error(
+ "Playwright is not installed. Run: npm install -D playwright && npx playwright install chromium",
+ );
+ }
+}
+
+export async function launchHumanBrowser(chromium, { headless = false } = {}) {
+ return chromium.launch({
+ headless,
+ slowMo: headless ? 0 : 40,
+ args: [
+ "--disable-blink-features=AutomationControlled",
+ "--disable-dev-shm-usage",
+ "--no-first-run",
+ "--no-default-browser-check",
+ ],
+ });
+}
+
+export async function applyStealth(page) {
+ await page.setExtraHTTPHeaders({
+ "Accept-Language": "en-US,en;q=0.9",
+ Accept:
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ });
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "webdriver", { get: () => false });
+ window.chrome = { runtime: {} };
+ });
+}
+
+export async function humanScroll(page, passes = 3) {
+ for (let i = 0; i < passes; i++) {
+ const delta = 280 + Math.floor(Math.random() * 520);
+ await page.mouse.wheel(0, delta);
+ await humanDelay(1200, 3400);
+ }
+}
+
+export const platformLogin = {
+ linkedin: "https://www.linkedin.com/login",
+ x: "https://x.com/i/flow/login",
+};
+
+export const platformHome = {
+ linkedin: "https://www.linkedin.com/feed/",
+ x: "https://x.com/home",
+};
+
+export const sessionCookieName = {
+ linkedin: "li_at",
+ x: "auth_token",
+};
+
+export async function uploadSession(platform, { cookies, userAgent, displayLabel }) {
+ const res = await fetch(`${base}/api/admin/growth/credentials`, {
+ method: "POST",
+ headers: authHeaders(),
+ body: JSON.stringify({
+ platform,
+ cookies,
+ userAgent,
+ displayLabel,
+ }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(json.error ?? `Upload failed HTTP ${res.status}`);
+ }
+}
+
+export async function pullSession(platform) {
+ const res = await fetch(
+ `${base}/api/admin/growth/connector/credentials?platform=${encodeURIComponent(platform)}`,
+ { headers: authHeaders() },
+ );
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(json.error ?? `Pull failed HTTP ${res.status}`);
+ }
+ return { cookies: json.cookies, userAgent: json.userAgent };
+}
+
+export async function reportHealth(platform, sessionStatus, extra = {}) {
+ const res = await fetch(`${base}/api/admin/growth/sessions/health`, {
+ method: "POST",
+ headers: authHeaders(),
+ body: JSON.stringify({
+ platform,
+ sessionStatus,
+ lastError: extra.lastError ?? null,
+ connectedAccountLabel: extra.connectedAccountLabel ?? null,
+ }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(json.error ?? `Health report failed HTTP ${res.status}`);
+ }
+}
+
+export async function waitForSessionCookie(context, platform, timeoutMs = 600_000) {
+ const name = sessionCookieName[platform];
+ const started = Date.now();
+ while (Date.now() - started < timeoutMs) {
+ const cookies = await context.cookies();
+ if (cookies.some((c) => c.name === name && c.value)) {
+ return true;
+ }
+ await sleep(2000);
+ }
+ return false;
+}
+
+export function withinBusinessHours() {
+ const hour = new Date().getHours();
+ return hour >= 8 && hour < 20;
+}
+
+export async function assertBusinessHours() {
+ if (process.env.GROWTH_IGNORE_BUSINESS_HOURS === "1") return;
+ if (!withinBusinessHours()) {
+ console.log(
+ "Outside local 08:00β20:00 β skipping harvest to avoid bot-like night activity. Set GROWTH_IGNORE_BUSINESS_HOURS=1 to override.",
+ );
+ process.exit(0);
+ }
+}
+
+export function writeSignalsFile(path, signals) {
+ fs.writeFileSync(path, JSON.stringify({ signals }, null, 2));
+}
+
+export async function pushSignals(fileOrSignals) {
+ let signals;
+ if (typeof fileOrSignals === "string") {
+ const raw = fs.readFileSync(fileOrSignals, "utf8");
+ const parsed = JSON.parse(raw);
+ signals = Array.isArray(parsed) ? parsed : parsed.signals;
+ } else {
+ signals = fileOrSignals;
+ }
+ if (!Array.isArray(signals) || signals.length === 0) {
+ throw new Error("No signals to push");
+ }
+ const res = await fetch(`${base}/api/admin/growth/connector/ingest`, {
+ method: "POST",
+ headers: authHeaders(),
+ body: JSON.stringify({ signals }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(json.error ?? `Ingest HTTP ${res.status}`);
+ }
+ return json.ingested ?? signals.length;
+}
diff --git a/scripts/growth-connect-server.mjs b/scripts/growth-connect-server.mjs
new file mode 100644
index 0000000..e7e129d
--- /dev/null
+++ b/scripts/growth-connect-server.mjs
@@ -0,0 +1,405 @@
+#!/usr/bin/env node
+/**
+ * Local connect helper: streams a real Chromium login session into an iframe in admin.
+ * Run: npm run growth:connect-server
+ *
+ * Env: GROWTH_CONNECTOR_SECRET, GROWTH_API_BASE (optional), GROWTH_CONNECT_PORT (default 9234)
+ */
+
+import fs from "node:fs";
+import http from "node:http";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+
+function loadEnvFile(filename) {
+ const envPath = path.join(process.cwd(), filename);
+ if (!fs.existsSync(envPath)) return;
+ for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) continue;
+ const eq = trimmed.indexOf("=");
+ if (eq === -1) continue;
+ const key = trimmed.slice(0, eq).trim();
+ const value = trimmed.slice(eq + 1).trim();
+ if (!process.env[key]) process.env[key] = value;
+ }
+}
+
+loadEnvFile(".env.local");
+loadEnvFile(".env");
+import {
+ applyStealth,
+ authHeaders,
+ base,
+ humanDelay,
+ launchHumanBrowser,
+ loadPlaywright,
+ platformHome,
+ platformLogin,
+ reportHealth,
+ uploadSession,
+ waitForSessionCookie,
+} from "./growth-browser-lib.mjs";
+
+const port = Number(process.env.GROWTH_CONNECT_PORT ?? 9234);
+const VIEWPORT = { width: 1100, height: 720 };
+
+/** @type {Map