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 πŸ‘‹ +
+ Stackwise mark +

Stackwise Technologies

+

+ International software engineering with African roots β€” AI systems, cloud infrastructure, + and digital products that are secure, scalable, and beautifully designed. +

+

+ Website + GitHub +

+

+ Stackwise Banner +

+
+ +
+ Table of Contents + +- [About](#-about) +- [What we build](#-what-we-build) +- [Tech stack](#️-our-tech-stack) +- [Website (this repo)](#️-website-this-repo) +- [Open source](#️-open-source-commitment) +- [Let's build together](#-lets-build-together) +
+ +--- + +## 🌍 About

- Stackwise Banner + Stackwise β€” engineering at the speed of your business

-We're an international software engineering company with African roots, building scalable software, AI systems, cloud infrastructure, and digital products for businesses worldwide. Our mission is to accelerate business growth by delivering world-class technology solutions that are secure, scalable, and beautifully designed. +We're an international software engineering company with African roots. We don't just write code; we solve business problems β€” from AI-powered workflows to production payments, cloud platforms, and product delivery for teams worldwide. + +**Website:** [stackwisetechnologies.com](https://stackwisetechnologies.com) + +

(back to top)

--- ## 🌍 What we build -At Stackwise, we take a strategic approach to software engineering. We don't just write code; we solve business problems. +
+ Quick overview +
+

+ AI Β· Cloud Β· Apps β€” built for scale +

+
### πŸ€– AI & Automation We integrate intelligent systems into your workflows. From AI-powered assistants and predictive analytics to automated operational tooling, we help companies reduce manual work and scale efficiently. @@ -27,6 +66,15 @@ We develop secure RESTful and GraphQL APIs that power everything from mobile app ### 🧩 Developer Tools We build utilities and open-source packages that help other engineers work faster, smarter, and with fewer friction points. +### πŸ’³ Payments & subscriptions +

+ Secure payments β€” Paystack, KPay, receipts +

+ +This repo also powers Stackwise’s client payment flows: one-time invoices (Paystack card + KPay Mobile Money), printable receipts, and Paystack recurring maintenance subscriptions. + +

(back to top)

+ --- ## πŸ› οΈ Our Tech Stack @@ -38,6 +86,7 @@ We utilize a modern, battle-tested technology stack to ensure our products are r - **Database:** PostgreSQL, MongoDB, Redis, Firebase - **Cloud & DevOps:** AWS, Google Cloud, Docker, Kubernetes, CI/CD - **AI/ML:** OpenAI, LangChain, TensorFlow +- **Payments:** Paystack, KPay, Neon Postgres --- @@ -66,26 +115,96 @@ Locales: | `npm run lint` | ESLint | | `npm test` | Unit tests (Vitest) | | `npm run typecheck` | TypeScript (`tsc --noEmit`) | +| `npm run db:migrate` | Apply SQL migrations to Neon (uses `.env.local` β†’ `.env.prod` β†’ `.env`) | +| `npm run db:migrate:prod` | Apply migrations to **production** Neon (`MIGRATE_ENV=prod`, reads `.env.prod` only) | +| `npm run db:reset:prod-payments` | Dry-run cleanup of sandbox rows in prod Neon; pass `--confirm` to apply | | `npm run preview` | Build OpenNext worker and preview in `workerd` via Wrangler | | `npm run build:worker` | Build the Cloudflare Worker bundle only | | `npm run deploy` | Build + deploy to Cloudflare Workers | -| `npm run deploy:live` | Build + deploy current branch/commit as the **live** production Worker (tagged) | +| `npm run deploy:live` | Full production deploy β€” see [Deploy to production](#deploy-to-production) | +| `npm run cf:secrets` | Upload Worker secrets from `.env.prod` without redeploying | | `npm run cf:whoami` | Show Cloudflare auth account | | `npm run cf:status` | Show the live Workers deployment status | -### Deploy from your machine +### Environment files + +| File | Purpose | +| --- | --- | +| `.env.local` | Local development (gitignored). Copy from `.env.example`. | +| `.env.prod` | Production secrets and config (gitignored). Used by deploy and prod migrations. | +| `.env.example` | Template with placeholder keys β€” safe to commit. | + +Never commit `.env.local` or `.env.prod`. + +### Database migrations + +Migrations live in `migrations/*.sql` and are applied in filename order. + +```bash +# Local / dev Neon +npm run db:migrate + +# Production Neon (uses DATABASE_URL from .env.prod) +npm run db:migrate:prod +``` + +The app also runs `ensureSchema()` on first DB access, which applies any pending migration files automatically. + +### Deploy to production You do not need GitHub Actions or the Cloudflare dashboard to ship: ```bash -# one-time: log in if needed +# One-time: log in to Cloudflare npx wrangler login -# ship the current branch as production +# Create .env.prod with production values (see .env.example) +# Then ship: npm run deploy:live ``` -`deploy:live` tags the Worker version with the current git branch and short SHA (and marks `-dirty` if you have uncommitted changes), then rolls it out to 100% production traffic. +#### What `npm run deploy:live` does + +Runs `scripts/deploy-live.sh`, which: + +1. **Checks auth** β€” exits if `wrangler whoami` fails. +2. **Build env** β€” copies `.env.prod` β†’ `.env.production.local` so `NEXT_PUBLIC_*` (e.g. site URL) is baked into the Next.js build, not localhost from `.env.local`. +3. **Sync secrets** β€” calls `scripts/sync-cloudflare-env.mjs` to upload every key in `.env.prod` to the `stackwise-technologies` Worker via `wrangler secret bulk`. +4. **Build** β€” runs `npm run build:worker` (OpenNext + Next.js production build). +5. **Deploy** β€” publishes to Cloudflare Workers at 100% traffic, tagged with git branch + short SHA (e.g. `main@abc1234`). + +#### Sync secrets only (no redeploy) + +After editing `.env.prod`, push new secrets without rebuilding: + +```bash +npm run cf:secrets +# equivalent to: node scripts/sync-cloudflare-env.mjs .env.prod +``` + +Requires `wrangler` login. Uploads all non-comment `KEY=value` lines from the file as encrypted Worker secrets. + +#### Script reference (`scripts/`) + +| File | Invoked by | Description | +| --- | --- | --- | +| `scripts/deploy-live.sh` | `npm run deploy:live` | End-to-end production deploy: env, secrets, build, publish. | +| `scripts/sync-cloudflare-env.mjs` | `npm run cf:secrets`, `deploy-live.sh` | Parses an env file and runs `wrangler secret bulk` for Worker `stackwise-technologies`. Optional arg: path to env file (default `.env.prod`). | +| `scripts/migrate.mjs` | `npm run db:migrate`, `npm run db:migrate:prod` | Loads env files, connects to Neon, runs all `migrations/*.sql` in order. Set `MIGRATE_ENV=prod` to force `.env.prod`. | +| `scripts/db-reset-prod-payments.mjs` | `npm run db:reset:prod-payments` | Removes test/sandbox payment links from production Neon and marks remaining drafts as live (`kpay_is_test = false`). Dry-run by default; `--confirm` to apply. | + +### Admin & payments (local) + +- Admin dashboard: [http://localhost:3000/admin](http://localhost:3000/admin) +- Payment admin uses Neon Postgres, Paystack (card), KPay (Mobile Money), and SMTP β€” configure in `.env.local`. +- For local payment E2E, set `NEXT_PUBLIC_SITE_URL=http://localhost:3000` in `.env.local`. +- See [docs/PAYMENTS.md](docs/PAYMENTS.md) for payment flow, receipts, and legal notices. + +### Payments legal & compliance + +Customer payment pages (`/pay/*`) include a legal notice linking to [Payment processing & data](/pay/legal). See [docs/PAYMENTS.md](docs/PAYMENTS.md) for how payments, receipts, and personal data are handled. + +

(back to top)

--- @@ -104,7 +223,7 @@ We're always excited to discuss new ideas, solve complex problems, and build the πŸ“« **Get in touch:** - **Website:** [https://stackwisetechnologies.com](https://stackwisetechnologies.com) - **Email:** info@stackwisetechnologies.com -- **Book a Call:** [Schedule a 30-min discovery call](https://calendly.com/stackwisetechnologies-info/30min) +- **Book a call:** Cal.com discovery link (see `NEXT_PUBLIC_CAL_COM_BOOKING_URL` in env; guest `tshikombadaniel@gmail.com` is always prefilled)

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} */ +const sessions = new Map(); + +async function validateToken(token) { + const res = await fetch(`${base}/api/admin/growth/connect/validate`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token }), + }); + if (!res.ok) return null; + const json = await res.json(); + return json.valid ? json.platform : null; +} + +async function detectAccountLabel(page, platform) { + try { + if (platform === "linkedin") { + const el = page.locator("button.global-nav__primary-link-me-menu-trigger").first(); + if (await el.count()) return (await el.getAttribute("aria-label")) ?? null; + } + if (platform === "x") { + const el = page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').first(); + if (await el.count()) return (await el.innerText()).trim() || null; + } + } catch { + /* optional */ + } + return null; +} + +async function startSession(platform, token) { + const id = randomUUID(); + const chromium = await loadPlaywright(); + const browser = await launchHumanBrowser(chromium, { headless: true }); + const context = await browser.newContext({ viewport: VIEWPORT }); + const page = await context.newPage(); + await applyStealth(page); + + const session = { + id, + platform, + browser, + context, + page, + status: "starting", + lastFrame: null, + lastError: null, + cdp: null, + }; + sessions.set(id, session); + + void (async () => { + try { + const client = await context.newCDPSession(page); + session.cdp = client; + await client.send("Page.startScreencast", { + format: "jpeg", + quality: 72, + everyNthFrame: 1, + }); + client.on("Page.screencastFrame", async (frame) => { + session.lastFrame = Buffer.from(frame.data, "base64"); + try { + await client.send("Page.screencastFrameAck", { sessionId: frame.sessionId }); + } catch { + /* closed */ + } + }); + + session.status = "login"; + await page.goto(platformLogin[platform], { waitUntil: "domcontentloaded", timeout: 120_000 }); + await humanDelay(1200, 2400); + + const gotCookie = await waitForSessionCookie(context, platform, 600_000); + if (!gotCookie) { + session.status = "error"; + session.lastError = "Login timed out"; + return; + } + + session.status = "syncing"; + await page.goto(platformHome[platform], { waitUntil: "domcontentloaded", timeout: 120_000 }); + await humanDelay(2000, 4000); + + const cookies = await context.cookies(); + const userAgent = await page.evaluate(() => navigator.userAgent); + const label = await detectAccountLabel(page, platform); + + await uploadSession(platform, { cookies, userAgent, displayLabel: label }); + + const url = page.url(); + let healthStatus = "active"; + let healthError = null; + if (url.includes("/login") || url.includes("/i/flow/login")) { + healthStatus = "expired"; + healthError = "Redirected to login"; + } else if (url.includes("checkpoint") || url.includes("challenge")) { + healthStatus = "challenge"; + healthError = "Verification required"; + } + + await reportHealth(platform, healthStatus, { + lastError: healthError, + connectedAccountLabel: label, + }); + + session.status = healthStatus === "active" ? "complete" : "error"; + session.lastError = healthError; + } catch (error) { + session.status = "error"; + session.lastError = error instanceof Error ? error.message : String(error); + try { + await reportHealth(platform, "expired", { lastError: session.lastError }); + } catch { + /* ignore */ + } + } + })(); + + return id; +} + +function readJson(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (error) { + reject(error); + } + }); + }); +} + +const EMBED_HTML = ` + + + + + Stackwise connect + + + +

+ Starting browser… + Click inside to interact Β· log in as you normally would +
+
+ +
+ + +`; + +function applyCors(req, res) { + const allowed = (process.env.GROWTH_CONNECT_ALLOWED_ORIGINS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const origin = req.headers.origin; + if (!allowed.length) { + res.setHeader("Access-Control-Allow-Origin", origin ?? "*"); + } else if (origin && allowed.includes(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + } + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); +} + +const server = http.createServer(async (req, res) => { + applyCors(req, res); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`); + + try { + if (url.pathname === "/health" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (url.pathname === "/v1/embed" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(EMBED_HTML); + return; + } + + if (url.pathname === "/v1/sessions" && req.method === "POST") { + const body = await readJson(req); + const platform = await validateToken(body.token); + if (!platform) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid or expired connect token" })); + return; + } + const sessionId = await startSession(platform, body.token); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ sessionId, embedUrl: `/v1/embed?session=${sessionId}` })); + return; + } + + const frameMatch = url.pathname.match(/^\/v1\/frame\/([^/]+)$/); + if (frameMatch && req.method === "GET") { + const session = sessions.get(frameMatch[1]); + if (!session?.lastFrame) { + res.writeHead(204); + res.end(); + return; + } + res.writeHead(200, { "Content-Type": "image/jpeg" }); + res.end(session.lastFrame); + return; + } + + const statusMatch = url.pathname.match(/^\/v1\/status\/([^/]+)$/); + if (statusMatch && req.method === "GET") { + const session = sessions.get(statusMatch[1]); + if (!session) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: session.status, + lastError: session.lastError, + platform: session.platform, + }), + ); + return; + } + + const inputMatch = url.pathname.match(/^\/v1\/input\/([^/]+)$/); + if (inputMatch && req.method === "POST") { + const session = sessions.get(inputMatch[1]); + if (!session?.page) { + res.writeHead(404); + res.end(); + return; + } + const body = await readJson(req); + const page = session.page; + if (body.type === "click") { + await page.mouse.click(body.x, body.y); + } else if (body.type === "key" && body.key?.length === 1) { + await page.keyboard.type(body.key); + } else if (body.type === "key" && body.key === "Enter") { + await page.keyboard.press("Enter"); + } else if (body.type === "key" && body.key === "Backspace") { + await page.keyboard.press("Backspace"); + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + res.writeHead(404); + res.end("Not found"); + } catch (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: error instanceof Error ? error.message : "error" })); + } +}); + +const host = process.env.GROWTH_CONNECT_HOST ?? "127.0.0.1"; +server.listen(port, host, () => { + console.log(`Growth connect server listening on http://${host}:${port}`); + if (host === "127.0.0.1") { + console.log("Keep this running while you use Connect in the admin UI."); + } +}); diff --git a/scripts/growth-connect.mjs b/scripts/growth-connect.mjs new file mode 100644 index 0000000..cb57778 --- /dev/null +++ b/scripts/growth-connect.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Headed browser login β†’ encrypted cookie upload β†’ session health ping. + * + * Usage: node scripts/growth-connect.mjs + */ + +import { + applyStealth, + humanDelay, + launchHumanBrowser, + loadPlaywright, + platformHome, + platformLogin, + reportHealth, + uploadSession, + waitForSessionCookie, +} from "./growth-browser-lib.mjs"; + +const platform = process.argv[2]; +if (platform !== "linkedin" && platform !== "x") { + console.error("Usage: node scripts/growth-connect.mjs "); + process.exit(1); +} + +async function detectAccountLabel(page, platform) { + try { + if (platform === "linkedin") { + const el = page.locator("button.global-nav__primary-link-me-menu-trigger").first(); + if (await el.count()) { + return (await el.getAttribute("aria-label")) ?? null; + } + } + if (platform === "x") { + const el = page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').first(); + if (await el.count()) { + return (await el.innerText()).trim() || null; + } + } + } catch { + /* optional */ + } + return null; +} + +async function validateSession(page, platform) { + const url = page.url(); + if (url.includes("/login") || url.includes("/i/flow/login")) { + return { status: "expired", error: "Redirected to login" }; + } + if (url.includes("checkpoint") || url.includes("challenge")) { + return { status: "challenge", error: "Verification or checkpoint required" }; + } + const label = await detectAccountLabel(page, platform); + return { status: "active", connectedAccountLabel: label }; +} + +async function main() { + const chromium = await loadPlaywright(); + const browser = await launchHumanBrowser(chromium, { headless: false }); + const context = await browser.newContext({ + viewport: { + width: 1280 + Math.floor(Math.random() * 80), + height: 800 + Math.floor(Math.random() * 60), + }, + }); + const page = await context.newPage(); + await applyStealth(page); + + console.log(`Opening ${platform} login β€” complete sign-in in the browser window.`); + await page.goto(platformLogin[platform], { waitUntil: "domcontentloaded" }); + await humanDelay(1500, 3000); + + const gotCookie = await waitForSessionCookie(context, platform); + if (!gotCookie) { + await browser.close(); + throw new Error("Timed out waiting for session cookie. Try again."); + } + + await humanDelay(2000, 4000); + await page.goto(platformHome[platform], { waitUntil: "domcontentloaded" }); + await humanDelay(2500, 5000); + + const cookies = await context.cookies(); + const userAgent = await page.evaluate(() => navigator.userAgent); + const label = await detectAccountLabel(page, platform); + + await uploadSession(platform, { + cookies, + userAgent, + displayLabel: label, + }); + console.log("Uploaded encrypted session to Stackwise."); + + const check = await validateSession(page, platform); + await reportHealth(platform, check.status, { + lastError: check.error ?? null, + connectedAccountLabel: check.connectedAccountLabel ?? label, + }); + console.log(`Session health: ${check.status}`); + + await humanDelay(800, 1500); + await browser.close(); +} + +main().catch(async (error) => { + console.error(error instanceof Error ? error.message : error); + try { + await reportHealth(platform, "expired", { + lastError: error instanceof Error ? error.message : String(error), + }); + } catch { + /* ignore */ + } + process.exit(1); +}); diff --git a/scripts/growth-connector.mjs b/scripts/growth-connector.mjs new file mode 100644 index 0000000..3b129aa --- /dev/null +++ b/scripts/growth-connector.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * Local growth connector CLI (session-cookie workflow). + * + * Env: + * GROWTH_CONNECTOR_SECRET (required, 32+ chars) + * GROWTH_API_BASE (default http://localhost:3000) + * + * Usage: + * node scripts/growth-connector.mjs push signals.json + * node scripts/growth-connector.mjs upload-cookies linkedin cookies.json + * node scripts/growth-connector.mjs pull-cookies linkedin -o cookies.json + */ + +import fs from "node:fs"; + +const base = process.env.GROWTH_API_BASE ?? "http://localhost:3000"; +const secret = process.env.GROWTH_CONNECTOR_SECRET; + +function usage() { + console.error(` +Usage: + growth-connector.mjs push + growth-connector.mjs upload-cookies + growth-connector.mjs pull-cookies [-o outfile.json] +`); + process.exit(1); +} + +function authHeaders() { + if (!secret || secret.length < 32) { + console.error("Set GROWTH_CONNECTOR_SECRET (32+ characters) in the environment."); + process.exit(1); + } + return { + "Content-Type": "application/json", + Authorization: `Bearer ${secret}`, + }; +} + +async function push(file) { + const raw = fs.readFileSync(file, "utf8"); + const parsed = JSON.parse(raw); + const signals = Array.isArray(parsed) ? parsed : parsed.signals; + if (!Array.isArray(signals) || signals.length === 0) { + throw new Error("File must be a signals array or { signals: [...] }"); + } + + 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 ?? `HTTP ${res.status}`); + } + console.log(`Ingested ${json.ingested ?? 0} signal(s).`); +} + +async function uploadCookies(platform, file) { + const cookies = JSON.parse(fs.readFileSync(file, "utf8")); + const res = await fetch(`${base}/api/admin/growth/credentials`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ platform, cookies }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(json.error ?? `HTTP ${res.status}`); + } + console.log(`Stored encrypted ${platform} session cookies.`); +} + +async function pullCookies(platform, outFile) { + 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 ?? `HTTP ${res.status}`); + } + const payload = JSON.stringify( + { userAgent: json.userAgent, cookies: json.cookies }, + null, + 2, + ); + if (outFile) { + fs.writeFileSync(outFile, payload); + console.log(`Wrote ${outFile}`); + } else { + process.stdout.write(`${payload}\n`); + } +} + +const [cmd, arg1, arg2, ...rest] = process.argv.slice(2); + +try { + if (cmd === "push" && arg1) { + await push(arg1); + } else if (cmd === "upload-cookies" && arg1 && arg2) { + await uploadCookies(arg1, arg2); + } else if (cmd === "pull-cookies" && arg1) { + const outIdx = rest.indexOf("-o"); + const outFile = + outIdx >= 0 ? rest[outIdx + 1] : arg2 && !arg2.startsWith("-") ? arg2 : null; + await pullCookies(arg1, outFile); + } else { + usage(); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/growth-harvest.mjs b/scripts/growth-harvest.mjs new file mode 100644 index 0000000..ffa7902 --- /dev/null +++ b/scripts/growth-harvest.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * Read-only harvest with human pacing. Pulls stored session, browses home feed, pushes signals. + * + * Usage: node scripts/growth-harvest.mjs [--dry-run] + */ + +import { + applyStealth, + assertBusinessHours, + humanDelay, + humanScroll, + launchHumanBrowser, + loadPlaywright, + platformHome, + pullSession, + pushSignals, + reportHealth, + sessionCookieName, +} from "./growth-browser-lib.mjs"; + +const platform = process.argv[2]; +const dryRun = process.argv.includes("--dry-run"); + +if (platform !== "linkedin" && platform !== "x") { + console.error("Usage: node scripts/growth-harvest.mjs [--dry-run]"); + process.exit(1); +} + +function authorFromBlockText(text) { + const line = text.split("\n").map((l) => l.trim()).find(Boolean); + return line && line.length <= 120 ? line : "Feed author"; +} + +async function extractLinkedInSignals(page) { + const articles = page.locator("div.feed-shared-update-v2"); + const count = Math.min(await articles.count(), 8); + const signals = []; + for (let i = 0; i < count; i++) { + const block = articles.nth(i); + const text = (await block.innerText().catch(() => "")).trim(); + if (text.length < 40) continue; + const link = await block.locator('a[href*="/feed/update"]').first().getAttribute("href").catch(() => null); + signals.push({ + platform: "linkedin", + sourceUrl: link ? (link.startsWith("http") ? link : `https://www.linkedin.com${link}`) : null, + authorName: authorFromBlockText(text), + authorHandle: null, + bodyText: text.slice(0, 4000), + capturedAt: new Date().toISOString(), + }); + await humanDelay(400, 1200); + } + return signals; +} + +async function extractXSignals(page) { + const articles = page.locator('article[data-testid="tweet"]'); + const count = Math.min(await articles.count(), 8); + const signals = []; + for (let i = 0; i < count; i++) { + const block = articles.nth(i); + const text = (await block.innerText().catch(() => "")).trim(); + if (text.length < 20) continue; + const link = await block.locator('a[href*="/status/"]').first().getAttribute("href").catch(() => null); + signals.push({ + platform: "x", + sourceUrl: link ? (link.startsWith("http") ? link : `https://x.com${link}`) : null, + authorName: authorFromBlockText(text), + authorHandle: null, + bodyText: text.slice(0, 4000), + capturedAt: new Date().toISOString(), + }); + await humanDelay(400, 1200); + } + return signals; +} + +async function validateSession(page) { + const url = page.url(); + if (url.includes("/login") || url.includes("/i/flow/login")) { + return { status: "expired", error: "Session expired β€” run growth-connect again" }; + } + if (url.includes("checkpoint") || url.includes("challenge")) { + return { status: "challenge", error: "Verification required in browser" }; + } + return { status: "active" }; +} + +async function main() { + await assertBusinessHours(); + + const { cookies, userAgent } = await pullSession(platform); + const cookieName = sessionCookieName[platform]; + const hasSession = Array.isArray(cookies) && cookies.some((c) => c.name === cookieName); + if (!hasSession) { + throw new Error(`Stored session missing ${cookieName}. Run growth-connect first.`); + } + + const chromium = await loadPlaywright(); + const browser = await launchHumanBrowser(chromium, { + headless: process.env.GROWTH_HEADLESS === "1", + }); + const context = await browser.newContext({ + userAgent: userAgent ?? undefined, + }); + await context.addCookies(cookies); + const page = await context.newPage(); + await applyStealth(page); + + await page.goto(platformHome[platform], { waitUntil: "domcontentloaded" }); + await humanDelay(3000, 6000); + + const check = await validateSession(page); + await reportHealth(platform, check.status, { lastError: check.error ?? null }); + if (check.status !== "active") { + await browser.close(); + throw new Error(check.error ?? "Session not active"); + } + + const passes = 2 + Math.floor(Math.random() * 3); + await humanScroll(page, passes); + await humanDelay(1500, 3500); + + const signals = + platform === "linkedin" + ? await extractLinkedInSignals(page) + : await extractXSignals(page); + + console.log(`Captured ${signals.length} signal(s) from ${platform}.`); + + if (!dryRun && signals.length > 0) { + const ingested = await pushSignals(signals); + console.log(`Ingested ${ingested} signal(s).`); + } else if (dryRun) { + console.log(JSON.stringify(signals, null, 2)); + } + + await humanDelay(500, 1200); + await browser.close(); +} + +main().catch(async (error) => { + console.error(error instanceof Error ? error.message : error); + try { + await reportHealth(platform, "expired", { + lastError: error instanceof Error ? error.message : String(error), + }); + } catch { + /* ignore */ + } + process.exit(1); +}); diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..b777fdf --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Apply SQL migrations in migrations/*.sql (sorted) to Neon Postgres. + * + * Usage: + * node scripts/migrate.mjs # .env.local β†’ .env.prod β†’ .env + * MIGRATE_ENV=prod node scripts/migrate.mjs # .env.prod only + */ +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; + } +} + +if (process.env.MIGRATE_ENV === "prod") { + loadEnvFile(".env.prod", { override: true }); +} else { + loadEnvFile(".env.local"); + loadEnvFile(".env.prod"); + loadEnvFile(".env"); + loadEnvFile(".env.example"); +} + +const url = process.env.DATABASE_URL; +if (!url) { + console.error("DATABASE_URL is required"); + process.exit(1); +} + +const sql = neon(url); +const migrationsDir = path.join(process.cwd(), "migrations"); +const files = fs + .readdirSync(migrationsDir) + .filter((name) => name.endsWith(".sql")) + .sort(); + +for (const file of files) { + const migrationPath = path.join(migrationsDir, file); + const migration = fs.readFileSync(migrationPath, "utf8"); + const statements = migration + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean); + + for (const statement of statements) { + await sql.query(statement); + console.log(`[${file}]`, statement.split("\n")[0].slice(0, 80)); + } +} + +console.log("Migration complete:", files.join(", ")); diff --git a/scripts/sync-cloudflare-env.mjs b/scripts/sync-cloudflare-env.mjs new file mode 100644 index 0000000..343fefb --- /dev/null +++ b/scripts/sync-cloudflare-env.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * Upload production env vars from .env.prod to the Cloudflare Worker as secrets. + * Usage: node scripts/sync-cloudflare-env.mjs [.env.prod] + */ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +const WORKER_NAME = "stackwise-technologies"; +const envFile = process.argv[2] ?? ".env.prod"; +const envPath = path.join(process.cwd(), envFile); + +if (!fs.existsSync(envPath)) { + console.error(`Missing env file: ${envPath}`); + process.exit(1); +} + +const secrets = {}; +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 (key) secrets[key] = value; +} + +if (Object.keys(secrets).length === 0) { + console.error("No variables found in env file"); + process.exit(1); +} + +const tmpPath = path.join(process.cwd(), ".wrangler-secrets.tmp.json"); +fs.writeFileSync(tmpPath, JSON.stringify(secrets, null, 2)); + +console.log(`Uploading ${Object.keys(secrets).length} secrets to Worker "${WORKER_NAME}" from ${envFile}…`); +console.log(`Keys: ${Object.keys(secrets).join(", ")}`); + +const result = spawnSync( + "npx", + ["wrangler", "secret", "bulk", tmpPath, "--name", WORKER_NAME], + { stdio: "inherit", cwd: process.cwd() }, +); + +fs.unlinkSync(tmpPath); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +console.log("Cloudflare secrets updated."); diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index bc9b61e..56ca763 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { Geist, Geist_Mono } from "next/font/google"; import { notFound } from "next/navigation"; +import { ClientProviders } from "@/components/ClientProviders"; +import { createPageMetadata } from "@/lib/metadata"; import { isLocale, locales, type Locale } from "@/lib/content"; import "../../styles/globals.css"; @@ -15,46 +17,16 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -export const metadata: Metadata = { - icons: [ - { - rel: "icon", - type: "image/svg+xml", - url: "/favicon.svg", - }, - ], - title: "Stackwise Technologies LTD | Your Engineering Partner for Product Development", - description: - "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery.", - openGraph: { - title: "Stackwise Technologies LTD | Your Engineering Partner for Product Development", - description: - "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery.", - url: "https://stackwisetechnologies.com", - siteName: "Stackwise Technologies Ltd", - images: [ - { - url: "https://stackwisetechnologies.com/images/og-landing-1200x630.png", - width: 1200, - height: 630, - }, - { - url: "https://stackwisetechnologies.com/images/og-landing-1200x1200.png", - width: 1200, - height: 1200, - }, - ], - locale: "en_US", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Stackwise Technologies LTD | Your Engineering Partner for Product Development", - description: - "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery.", - images: ["https://stackwisetechnologies.com/image/og-landing-1200x630.png"], - }, +const MARKETING_TITLE = + "Stackwise Technologies LTD | Your Engineering Partner for Product Development"; +const MARKETING_DESCRIPTION = + "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery."; +export const metadata: Metadata = { + ...createPageMetadata({ + title: MARKETING_TITLE, + description: MARKETING_DESCRIPTION, + }), keywords: [ "Stackwise Technologies", "Engineering Partner", @@ -110,6 +82,24 @@ export const metadata: Metadata = { "Software Engineering Solutions", "Product Development Services", ], + openGraph: { + ...createPageMetadata({ + title: MARKETING_TITLE, + description: MARKETING_DESCRIPTION, + }).openGraph, + images: [ + { + url: "https://stackwisetechnologies.com/images/og-landing-1200x630.png", + width: 1200, + height: 630, + }, + { + url: "https://stackwisetechnologies.com/images/og-landing-1200x1200.png", + width: 1200, + height: 1200, + }, + ], + }, }; export function generateStaticParams() { @@ -133,7 +123,7 @@ export default async function LocaleLayout({ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + {children} ); diff --git a/src/app/admin/audit-logs/page.tsx b/src/app/admin/audit-logs/page.tsx new file mode 100644 index 0000000..1ab620d --- /dev/null +++ b/src/app/admin/audit-logs/page.tsx @@ -0,0 +1,29 @@ +import { redirect } from "next/navigation"; +import { AdminPageHeader } from "@/components/admin/AdminPageHeader"; +import { PaymentAuditLogsPanel } from "@/components/admin/PaymentAuditLogsPanel"; +import { getAdminSession } from "@/lib/auth/session"; +import { listRecentPaymentAuditLogs } from "@/lib/db/payment-audit"; + +export const metadata = { + title: "Access logs | Admin", +}; + +export default async function AuditLogsPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const logs = await listRecentPaymentAuditLogs(); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/src/app/admin/contracts/[id]/page.tsx b/src/app/admin/contracts/[id]/page.tsx new file mode 100644 index 0000000..d6464ea --- /dev/null +++ b/src/app/admin/contracts/[id]/page.tsx @@ -0,0 +1,74 @@ +import { ContractEngagementOpsPanel } from "@/components/admin/ContractEngagementOpsPanel"; +import { ContractReviewPanel } from "@/components/admin/ContractReviewPanel"; +import { ContractStatusBadge } from "@/components/admin/ContractStatusBadge"; +import { ClientContractDocumentPreview } from "@/components/contracts/ClientContractDocumentPreview"; +import { getAdminSession } from "@/lib/auth/session"; +import { contractClientUrl } from "@/lib/contracts/urls"; +import { getContractEngagementById } from "@/lib/db/contracts"; +import { listPaymentLinksForEngagement } from "@/lib/db/payment-links"; +import { notFound, redirect } from "next/navigation"; + +export default async function AdminContractDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const { id } = await params; + const engagement = await getContractEngagementById(id); + if (!engagement) notFound(); + + const clientUrl = contractClientUrl(engagement); + const paymentLinks = await listPaymentLinksForEngagement(engagement.id); + + const inReview = + engagement.status === "PENDING_REVIEW" || + engagement.status === "FEEDBACK_RECEIVED" || + engagement.status === "GENERATION_FAILED"; + + const readOnlySent = engagement.status === "SENT"; + + return ( +
+
+
+

{engagement.reference}

+
+ +

{engagement.customerName}

+
+
+
+ + {engagement.status === "GENERATING" && ( +

Generating draft… refresh in a moment.

+ )} + + {engagement.status !== "GENERATING" && engagement.status !== "PENDING_REVIEW" && ( +
+

Client portal

+ + {clientUrl} + +
+ )} + + + + {inReview && } + + {readOnlySent && } + + {(engagement.status === "ACCEPTED" || + engagement.status === "IN_PROGRESS" || + engagement.status === "COMPLETED") && ( + + )} +
+ ); +} diff --git a/src/app/admin/contracts/new/page.tsx b/src/app/admin/contracts/new/page.tsx new file mode 100644 index 0000000..b39ceca --- /dev/null +++ b/src/app/admin/contracts/new/page.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ContractBuilder } from "@/components/admin/ContractBuilder"; +import { getAdminSession } from "@/lib/auth/session"; + +export default async function NewContractPage({ + searchParams, +}: { + searchParams: Promise<{ + name?: string; + email?: string; + company?: string; + brief?: string; + leadId?: string; + }>; +}) { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const q = await searchParams; + + return ( +
+
+ + ← Agreements + +

New agreement

+

+ Describe what the prospect needs. AI drafts the document; you approve before + anything is emailed. +

+
+ + +
+ ); +} diff --git a/src/app/admin/contracts/page.tsx b/src/app/admin/contracts/page.tsx new file mode 100644 index 0000000..26af7cf --- /dev/null +++ b/src/app/admin/contracts/page.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { AdminPageHeader } from "@/components/admin/AdminPageHeader"; +import { ContractsList } from "@/components/admin/ContractsList"; +import { getAdminSession } from "@/lib/auth/session"; +import { listContractEngagements } from "@/lib/db/contracts"; +import { ADMIN_BTN_PRIMARY_BLOCK } from "@/lib/ui/admin"; + +export default async function AdminContractsPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const engagements = await listContractEngagements(); + + return ( +
+ + New agreement + + } + /> + + + + {engagements.length === 0 && ( +
+

No agreements yet.

+ + Draft your first agreement + +
+ )} +
+ ); +} diff --git a/src/app/admin/growth/connect/page.tsx b/src/app/admin/growth/connect/page.tsx new file mode 100644 index 0000000..978b013 --- /dev/null +++ b/src/app/admin/growth/connect/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Suspense } from "react"; +import { AdminPageHeader } from "@/components/admin/AdminPageHeader"; +import { GrowthConnectPanel } from "@/components/admin/growth/GrowthConnectPanel"; +import { useSearchParams } from "next/navigation"; + +function GrowthConnectContent() { + const params = useSearchParams(); + const platform = params.get("platform") === "x" ? "x" : "linkedin"; + const label = platform === "x" ? "X" : "LinkedIn"; + + return ( +
+ + +
+ ); +} + +export default function GrowthConnectPage() { + return ( + Loading connect…

} + > + +
+ ); +} diff --git a/src/app/admin/growth/layout.tsx b/src/app/admin/growth/layout.tsx new file mode 100644 index 0000000..caab98b --- /dev/null +++ b/src/app/admin/growth/layout.tsx @@ -0,0 +1,10 @@ +import { GrowthLayoutClient } from "@/components/admin/growth/GrowthLayoutClient"; +import type { ReactNode } from "react"; +import { redirect } from "next/navigation"; +import { getAdminSession } from "@/lib/auth/session"; + +export default async function GrowthLayout({ children }: { children: ReactNode }) { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + return {children}; +} diff --git a/src/app/admin/growth/leads/page.tsx b/src/app/admin/growth/leads/page.tsx new file mode 100644 index 0000000..7812d37 --- /dev/null +++ b/src/app/admin/growth/leads/page.tsx @@ -0,0 +1,14 @@ +import { GrowthLeadsTable } from "@/components/admin/growth/GrowthLeadsTable"; +import { AdminPageHeader } from "@/components/admin/AdminPageHeader"; + +export default function GrowthLeadsPage() { + return ( +
+ + +
+ ); +} diff --git a/src/app/admin/growth/page.tsx b/src/app/admin/growth/page.tsx new file mode 100644 index 0000000..dd238a6 --- /dev/null +++ b/src/app/admin/growth/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { GrowthCaptureForm } from "@/components/admin/growth/GrowthCaptureForm"; +import { GrowthHarvestPanel } from "@/components/admin/growth/GrowthHarvestPanel"; +import { GrowthInboxPanel } from "@/components/admin/growth/GrowthInboxPanel"; +import { AdminPageHeader } from "@/components/admin/AdminPageHeader"; +import { useState } from "react"; + +export default function GrowthInboxPage() { + const [inboxKey, setInboxKey] = useState(0); + + return ( +
+ + +
+ setInboxKey((k) => k + 1)} /> + setInboxKey((k) => k + 1)} /> +
+
+ ); +} diff --git a/src/app/admin/growth/settings/page.tsx b/src/app/admin/growth/settings/page.tsx new file mode 100644 index 0000000..f1e999f --- /dev/null +++ b/src/app/admin/growth/settings/page.tsx @@ -0,0 +1,14 @@ +import { GrowthSettingsPanel } from "@/components/admin/growth/GrowthSettingsPanel"; +import { AdminPageHeader } from "@/components/admin/AdminPageHeader"; + +export default function GrowthSettingsPage() { + return ( +
+ + +
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..a444b41 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { Geist, Geist_Mono } from "next/font/google"; +import { AdminShell } from "@/components/admin/AdminShell"; +import { ClientProviders } from "@/components/ClientProviders"; +import { getAdminSession } from "@/lib/auth/session"; +import { gravatarUrl } from "@/lib/gravatar"; +import { createPageMetadata } from "@/lib/metadata"; +import "../../styles/globals.css"; +import "../../styles/brand-document.css"; +import "../../styles/print-document.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata = createPageMetadata({ + title: "Admin | Stackwise Technologies", + description: + "Stackwise Technologies payments admin β€” manage invoices, payment links, withdrawals, and access logs.", + path: "/admin", + noIndex: true, +}); + +export default async function AdminLayout({ children }: { children: ReactNode }) { + const session = await getAdminSession(); + const user = session + ? { email: session.email, avatarUrl: gravatarUrl(session.email, 72) } + : null; + + return ( + + + + {children} + + + + ); +} diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx new file mode 100644 index 0000000..f48fae3 --- /dev/null +++ b/src/app/admin/login/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { OtpInput } from "@/components/admin/OtpInput"; +import Mark from "@/components/Mark"; +import { useToast } from "@/components/ToastProvider"; +import { SITE_NAME } from "@/lib/content"; +import { BTN_GHOST, BTN_PRIMARY } from "@/lib/ui/buttons"; + +export default function AdminLoginPage() { + const router = useRouter(); + const toast = useToast(); + const [step, setStep] = useState<"email" | "otp">("email"); + const [email, setEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [loading, setLoading] = useState(false); + + async function sendOtp(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + try { + const res = await fetch("/api/admin/login/otp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + const data = (await res.json()) as { error?: string }; + if (!res.ok) throw new Error(data.error ?? "Unable to send code"); + setStep("otp"); + toast.toast("If your email is authorized, a verification code was sent."); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Unable to send code"); + } finally { + setLoading(false); + } + } + + async function verifyOtpWithCode(code: string) { + setLoading(true); + try { + const res = await fetch("/api/admin/login/otp/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, otp: code }), + }); + const data = (await res.json()) as { error?: string }; + if (!res.ok) throw new Error(data.error ?? "Invalid code"); + router.push("/admin"); + router.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Invalid code"); + } finally { + setLoading(false); + } + } + + async function verifyOtp(e: React.FormEvent) { + e.preventDefault(); + await verifyOtpWithCode(otp); + } + + return ( +
+
+
+ +
+

{SITE_NAME}

+

Admin sign in with email code

+
+
+ + {step === "email" ? ( +
+ + +
+ ) : ( +
+

+ Enter the 6-digit code sent to {email} +

+
+

+ Verification code +

+ { + if (!loading) { + void verifyOtpWithCode(code); + } + }} + /> +
+ + +
+ )} +
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..a5bef8f --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { AdminCheckoutStatus } from "@/components/admin/AdminCheckoutStatus"; +import { AdminPageHeader } from "@/components/admin/AdminPageHeader"; +import { PaymentLinksList } from "@/components/admin/PaymentLinksList"; +import { getAdminSession } from "@/lib/auth/session"; +import { listPaymentLinks } from "@/lib/db/payment-links"; +import { isCheckoutTestMode } from "@/lib/payments/admin-checkout"; +import { cardCheckoutProcessor } from "@/lib/payments/processor"; +import { ADMIN_BTN_PRIMARY_BLOCK } from "@/lib/ui/admin"; + +export default async function AdminDashboardPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const showTestSection = isCheckoutTestMode(); + const [liveLinks, testLinks] = showTestSection + ? await Promise.all([ + listPaymentLinks("production"), + listPaymentLinks("test"), + ]) + : [await listPaymentLinks("production"), []]; + + return ( +
+ +

Signed in as {session.email}

+ +
+ } + actions={ + + New payment link + + } + /> + + + + {liveLinks.length === 0 && !showTestSection && ( +
+

No payment links yet.

+ + Create your first link + +
+ )} + + ); +} diff --git a/src/app/admin/payment-links/[id]/page.tsx b/src/app/admin/payment-links/[id]/page.tsx new file mode 100644 index 0000000..a9b8b44 --- /dev/null +++ b/src/app/admin/payment-links/[id]/page.tsx @@ -0,0 +1,120 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { getAdminSession } from "@/lib/auth/session"; +import { getPaymentLinkById } from "@/lib/db/payment-links"; +import { EnvironmentBadge } from "@/components/admin/EnvironmentBadge"; +import { GatewayPaymentSummary } from "@/components/admin/GatewayPaymentSummary"; +import { PaymentAuditLog } from "@/components/admin/PaymentAuditLog"; +import { ManualPaymentSummary } from "@/components/admin/ManualPaymentSummary"; +import { RecordManualPaymentForm } from "@/components/admin/RecordManualPaymentForm"; +import { ResendReceiptButton } from "@/components/admin/ResendReceiptButton"; +import { SendLinkButton } from "@/components/admin/SendLinkButton"; +import { InvoiceView } from "@/components/payments/InvoiceView"; +import { resolvePaymentEnvironment } from "@/lib/kpay/environment"; +import { canAccessReceipt } from "@/lib/payments/receipt-access"; +import { cardCheckoutProcessor } from "@/lib/payments/processor"; +import { + PAYMENT_METHODS_OPTIONS, + paymentMethodOptionDescription, +} from "@/lib/payments/payment-methods"; +import { BTN_SECONDARY } from "@/lib/ui/buttons"; + +function siteUrl() { + return process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; +} + +export default async function PaymentLinkDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const { id } = await params; + const link = await getPaymentLinkById(id); + if (!link) notFound(); + + const payUrl = `${siteUrl()}/pay/${link.slug}`; + const cardProcessor = cardCheckoutProcessor(); + + return ( +
+
+
+ + ← Back to dashboard + +

{link.invoiceNumber}

+
+ +

{link.customerName}

+
+
+
+ {!canAccessReceipt(link) && } + {canAccessReceipt(link) && ( + <> + + View receipt + + + + )} +
+
+ +
+
+

Payment methods

+

+ { + PAYMENT_METHODS_OPTIONS.find( + (option) => option.value === link.allowedPaymentMethods, + )?.label + } +

+

+ {paymentMethodOptionDescription( + link.allowedPaymentMethods, + cardProcessor, + )} +

+
+
+

Payment link

+ + {payUrl} + +
+
+ + + + + + + + {canAccessReceipt(link) && ( +
+

Customer receipt

+

+ Preview what the customer sees. Use "View receipt" above to + open the printable receipt or email it directly. +

+
+ +
+
+ )} + + +
+ ); +} diff --git a/src/app/admin/payment-links/new/page.tsx b/src/app/admin/payment-links/new/page.tsx new file mode 100644 index 0000000..a496575 --- /dev/null +++ b/src/app/admin/payment-links/new/page.tsx @@ -0,0 +1,20 @@ +import { redirect } from "next/navigation"; +import { getAdminSession } from "@/lib/auth/session"; +import { PaymentLinkBuilder } from "@/components/admin/PaymentLinkBuilder"; +import { cardCheckoutProcessor } from "@/lib/payments/processor"; +import { getPaystackChargeCurrencies } from "@/lib/paystack/currencies"; +import { isPaystackConfigured } from "@/lib/paystack/environment"; + +export default async function NewPaymentLinkPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + return ( + + ); +} diff --git a/src/app/admin/subscriptions/[id]/page.tsx b/src/app/admin/subscriptions/[id]/page.tsx new file mode 100644 index 0000000..947b6e3 --- /dev/null +++ b/src/app/admin/subscriptions/[id]/page.tsx @@ -0,0 +1,99 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { SendSubscriptionButton } from "@/components/admin/SendSubscriptionButton"; +import { getAdminSession } from "@/lib/auth/session"; +import { formatMoney } from "@/lib/currency"; +import { formatDateTime } from "@/lib/format-datetime"; +import { + getMaintenanceSubscriptionById, + listSubscriptionChargeEvents, +} from "@/lib/db/maintenance-subscriptions"; +import { + formatSubscriptionInterval, + subscriptionAmountMajor, +} from "@/lib/subscriptions/types"; +import { BTN_SECONDARY } from "@/lib/ui/buttons"; + +function siteUrl() { + return process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; +} + +export default async function SubscriptionDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const { id } = await params; + const sub = await getMaintenanceSubscriptionById(id); + if (!sub) notFound(); + + const charges = await listSubscriptionChargeEvents(sub.id); + const subscribeUrl = `${siteUrl()}/pay/subscribe/${sub.slug}`; + + return ( +
+
+
+ + ← Subscriptions + +

{sub.description}

+

+ {sub.customerName} Β· {sub.customerEmail} +

+
+ {sub.status !== "ACTIVE" && } +
+ +
+
+

Billing

+

+ {formatMoney(subscriptionAmountMajor(sub), sub.currency)} Β·{" "} + {formatSubscriptionInterval(sub.interval)} +

+

Status: {sub.status}

+ {sub.paystackPlanCode && ( +

Plan {sub.paystackPlanCode}

+ )} +
+ +
+ +
+

Successful charges

+ {charges.length === 0 ? ( +

No charges recorded yet.

+ ) : ( +
    + {charges.map((charge) => ( +
  • + {charge.paystackReference} + + {formatMoney(charge.amountMinor / 100, charge.currency)} Β·{" "} + {formatDateTime(charge.paidAt)} + +
  • + ))} +
+ )} +
+ +

+ Cancel or change payout settings in the Paystack dashboard. KPay withdrawals remain + under Admin β†’ Withdraw. +

+
+ ); +} diff --git a/src/app/admin/subscriptions/new/page.tsx b/src/app/admin/subscriptions/new/page.tsx new file mode 100644 index 0000000..ddf48ff --- /dev/null +++ b/src/app/admin/subscriptions/new/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from "next/navigation"; +import { MaintenanceSubscriptionBuilder } from "@/components/admin/MaintenanceSubscriptionBuilder"; +import { getAdminSession } from "@/lib/auth/session"; +import { SUPPORTED_CURRENCIES } from "@/lib/payments/types"; +import { getPaystackChargeCurrencies } from "@/lib/paystack/currencies"; +import { isPaystackConfigured } from "@/lib/paystack/environment"; + +export default async function NewSubscriptionPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + if (!isPaystackConfigured()) redirect("/admin/subscriptions"); + + const allowed = new Set(getPaystackChargeCurrencies()); + const currencies = SUPPORTED_CURRENCIES.filter((c) => allowed.has(c.code)); + + return ( + + ); +} diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx new file mode 100644 index 0000000..1d9389a --- /dev/null +++ b/src/app/admin/subscriptions/page.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getAdminSession } from "@/lib/auth/session"; +import { listMaintenanceSubscriptions } from "@/lib/db/maintenance-subscriptions"; +import { isPaystackConfigured } from "@/lib/paystack/environment"; +import { AdminPageHeader } from "@/components/admin/AdminPageHeader"; +import { SubscriptionsTable } from "@/components/admin/SubscriptionsTable"; +import { ADMIN_BTN_PRIMARY_BLOCK } from "@/lib/ui/admin"; + +export default async function SubscriptionsAdminPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const paystackEnabled = isPaystackConfigured(); + const subscriptions = paystackEnabled ? await listMaintenanceSubscriptions() : []; + + return ( +
+ + New subscription + + ) : undefined + } + /> + + {!paystackEnabled && ( +
+ Add PAYSTACK_SECRET_KEY to enable + recurring subscriptions. +
+ )} + + {paystackEnabled && subscriptions.length === 0 && ( +

No subscriptions yet.

+ )} + + {subscriptions.length > 0 && ( + + )} +
+ ); +} diff --git a/src/app/admin/withdraw/page.tsx b/src/app/admin/withdraw/page.tsx new file mode 100644 index 0000000..c336599 --- /dev/null +++ b/src/app/admin/withdraw/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from "next/navigation"; +import { WithdrawPanel } from "@/components/admin/WithdrawPanel"; +import { getAdminSession } from "@/lib/auth/session"; +import { isKPayTestMode } from "@/lib/kpay/environment"; +import { isPaystackConfigured } from "@/lib/paystack/environment"; + +export default async function WithdrawPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const environment = isKPayTestMode() ? "test" : "production"; + return ( + + ); +} diff --git a/src/app/api/admin/contracts/[id]/payment-links/route.ts b/src/app/api/admin/contracts/[id]/payment-links/route.ts new file mode 100644 index 0000000..f0feeb0 --- /dev/null +++ b/src/app/api/admin/contracts/[id]/payment-links/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { createEngagementPaymentLink } from "@/lib/contracts/actions"; +import type { LineItem } from "@/lib/payments/types"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = (await request.json()) as { + currency?: string; + lineItems?: LineItem[]; + notes?: string; + sendEmail?: boolean; + }; + + if (!body.lineItems?.length) { + return NextResponse.json({ error: "lineItems required" }, { status: 400 }); + } + + try { + const result = await createEngagementPaymentLink(id, { + currency: body.currency ?? process.env.DEFAULT_PAYMENT_CURRENCY ?? "KES", + lineItems: body.lineItems, + notes: body.notes, + sendEmail: body.sendEmail ?? false, + }); + return NextResponse.json(result, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/admin/contracts/[id]/regenerate/route.ts b/src/app/api/admin/contracts/[id]/regenerate/route.ts new file mode 100644 index 0000000..f31b445 --- /dev/null +++ b/src/app/api/admin/contracts/[id]/regenerate/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { regenerateContractDraft } from "@/lib/contracts/actions"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + try { + const engagement = await regenerateContractDraft(id); + return NextResponse.json({ engagement }); + } catch (error) { + const message = error instanceof Error ? error.message : "Regenerate failed"; + const status = message.includes("not found") ? 404 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/admin/contracts/[id]/route.ts b/src/app/api/admin/contracts/[id]/route.ts new file mode 100644 index 0000000..323752c --- /dev/null +++ b/src/app/api/admin/contracts/[id]/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { saveContractDraftEdits } from "@/lib/contracts/actions"; +import { getContractEngagementById } from "@/lib/db/contracts"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const engagement = await getContractEngagementById(id); + if (!engagement) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + return NextResponse.json({ engagement }); +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = (await request.json()) as { + title?: string; + bodyMarkdown?: string; + }; + + try { + const engagement = await saveContractDraftEdits(id, body); + return NextResponse.json({ engagement }); + } catch (error) { + const message = error instanceof Error ? error.message : "Update failed"; + const status = message.includes("not found") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/admin/contracts/[id]/send/route.ts b/src/app/api/admin/contracts/[id]/send/route.ts new file mode 100644 index 0000000..34a3e74 --- /dev/null +++ b/src/app/api/admin/contracts/[id]/send/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { approveAndSendContractToClient } from "@/lib/contracts/actions"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + try { + const engagement = await approveAndSendContractToClient(id); + return NextResponse.json({ engagement }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to send"; + const status = message.includes("not found") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/admin/contracts/[id]/tracking/route.ts b/src/app/api/admin/contracts/[id]/tracking/route.ts new file mode 100644 index 0000000..a757352 --- /dev/null +++ b/src/app/api/admin/contracts/[id]/tracking/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { updateEngagementTracking } from "@/lib/contracts/actions"; +import { parseMilestones } from "@/lib/contracts/milestones"; +import { CONTRACT_DELIVERY_PHASES } from "@/lib/contracts/types"; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = (await request.json()) as { + deliveryPhase?: string; + progressHeadline?: string | null; + progressDetail?: string | null; + milestones?: unknown; + }; + + if ( + body.deliveryPhase && + !CONTRACT_DELIVERY_PHASES.includes(body.deliveryPhase as typeof CONTRACT_DELIVERY_PHASES[number]) + ) { + return NextResponse.json({ error: "Invalid phase" }, { status: 400 }); + } + + try { + const engagement = await updateEngagementTracking(id, { + deliveryPhase: body.deliveryPhase as typeof CONTRACT_DELIVERY_PHASES[number], + progressHeadline: body.progressHeadline, + progressDetail: body.progressDetail, + milestones: body.milestones !== undefined + ? parseMilestones(body.milestones) + : undefined, + }); + return NextResponse.json({ engagement }); + } catch (error) { + const message = error instanceof Error ? error.message : "Update failed"; + return NextResponse.json( + { error: message }, + { status: message.includes("not found") ? 404 : 400 }, + ); + } +} diff --git a/src/app/api/admin/contracts/route.ts b/src/app/api/admin/contracts/route.ts new file mode 100644 index 0000000..89d17cd --- /dev/null +++ b/src/app/api/admin/contracts/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { createAndGenerateContract } from "@/lib/contracts/actions"; +import { + CONTRACT_DOCUMENT_KINDS, + type ContractDocumentKind, +} from "@/lib/contracts/types"; +import { listContractEngagements } from "@/lib/db/contracts"; + +export async function GET() { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const engagements = await listContractEngagements(); + return NextResponse.json({ engagements }); +} + +export async function POST(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json()) as { + customerName?: string; + customerEmail?: string; + companyName?: string; + documentKind?: string; + servicesBrief?: string; + commercialNotes?: string; + leadId?: string; + }; + + const customerName = body.customerName?.trim(); + const customerEmail = body.customerEmail?.trim(); + const servicesBrief = body.servicesBrief?.trim(); + const documentKind = body.documentKind as ContractDocumentKind; + + if (!customerName || !customerEmail || !servicesBrief) { + return NextResponse.json( + { error: "customerName, customerEmail, and servicesBrief are required" }, + { status: 400 }, + ); + } + + if (!CONTRACT_DOCUMENT_KINDS.includes(documentKind)) { + return NextResponse.json({ error: "Invalid document kind" }, { status: 400 }); + } + + try { + const engagement = await createAndGenerateContract({ + customerName, + customerEmail, + companyName: body.companyName?.trim(), + documentKind, + servicesBrief, + commercialNotes: body.commercialNotes?.trim(), + leadId: body.leadId?.trim() || null, + }); + return NextResponse.json({ engagement }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create contract" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/growth/connect/browser/health/route.ts b/src/app/api/admin/growth/connect/browser/health/route.ts new file mode 100644 index 0000000..dd4c349 --- /dev/null +++ b/src/app/api/admin/growth/connect/browser/health/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { getHostedWorkerBrowser } from "@/lib/growth/hosted-connect"; + +export async function GET() { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + const worker = await getHostedWorkerBrowser(); + if (!worker) { + return NextResponse.json( + { ok: false, error: "Browser binding unavailable" }, + { status: 503 }, + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/growth/connect/browser/v1/embed/route.ts b/src/app/api/admin/growth/connect/browser/v1/embed/route.ts new file mode 100644 index 0000000..6b6abd7 --- /dev/null +++ b/src/app/api/admin/growth/connect/browser/v1/embed/route.ts @@ -0,0 +1,14 @@ +import { connectEmbedHtml } from "@/lib/growth/connect-embed-html"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; + +export async function GET() { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + return new Response(connectEmbedHtml(), { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/admin/growth/connect/browser/v1/frame/[id]/route.ts b/src/app/api/admin/growth/connect/browser/v1/frame/[id]/route.ts new file mode 100644 index 0000000..53969f7 --- /dev/null +++ b/src/app/api/admin/growth/connect/browser/v1/frame/[id]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { + captureHostedConnectFrame, + getHostedWorkerBrowser, +} from "@/lib/growth/hosted-connect"; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + const worker = await getHostedWorkerBrowser(); + if (!worker) { + return new NextResponse(null, { status: 503 }); + } + + const { id } = await context.params; + const frame = await captureHostedConnectFrame(worker.env, id); + if (!frame) { + return new NextResponse(null, { status: 204 }); + } + + return new Response(frame.buffer as ArrayBuffer, { + headers: { + "Content-Type": "image/jpeg", + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/admin/growth/connect/browser/v1/input/[id]/route.ts b/src/app/api/admin/growth/connect/browser/v1/input/[id]/route.ts new file mode 100644 index 0000000..01fd321 --- /dev/null +++ b/src/app/api/admin/growth/connect/browser/v1/input/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { + getHostedWorkerBrowser, + sendHostedConnectInput, +} from "@/lib/growth/hosted-connect"; + +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + const worker = await getHostedWorkerBrowser(); + if (!worker) { + return NextResponse.json({ error: "Browser unavailable" }, { status: 503 }); + } + + const { id } = await context.params; + const body = (await request.json()) as { + type?: string; + x?: number; + y?: number; + key?: string; + }; + + try { + await sendHostedConnectInput(worker.env, id, { + type: body.type ?? "", + x: body.x, + y: body.y, + key: body.key, + }); + return NextResponse.json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Input failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/connect/browser/v1/sessions/route.ts b/src/app/api/admin/growth/connect/browser/v1/sessions/route.ts new file mode 100644 index 0000000..a271e1d --- /dev/null +++ b/src/app/api/admin/growth/connect/browser/v1/sessions/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { verifyGrowthConnectToken } from "@/lib/growth/connect-token"; +import { + getHostedWorkerBrowser, + startHostedConnectSession, +} from "@/lib/growth/hosted-connect"; + +export async function POST(request: Request) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + try { + const body = (await request.json()) as { token?: string }; + if (!body.token?.trim()) { + return NextResponse.json({ error: "token required" }, { status: 400 }); + } + + const platform = await verifyGrowthConnectToken(body.token.trim()); + if (!platform) { + return NextResponse.json( + { error: "Invalid or expired connect token" }, + { status: 401 }, + ); + } + + const worker = await getHostedWorkerBrowser(); + if (!worker) { + return NextResponse.json( + { error: "Hosted browser not available on this deployment" }, + { status: 503 }, + ); + } + + const { sessionId } = await startHostedConnectSession( + worker.env, + platform, + worker.ctx, + ); + + return NextResponse.json({ + sessionId, + interaction: "stream", + embedUrl: `/api/admin/growth/connect/browser/v1/embed?session=${sessionId}`, + }); + } catch (error) { + const raw = error instanceof Error ? error.message : "Session failed"; + const localHint = + process.env.NODE_ENV === "development" + ? " For local dev, run npm run growth:connect-server (Miniflare browser launch is not supported)." + : ""; + const message = raw.includes("miniflare") || raw.includes("central directory") + ? `Cloudflare browser launch failed.${localHint}` + : `${raw}${localHint}`; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/connect/browser/v1/status/[id]/route.ts b/src/app/api/admin/growth/connect/browser/v1/status/[id]/route.ts new file mode 100644 index 0000000..9f12638 --- /dev/null +++ b/src/app/api/admin/growth/connect/browser/v1/status/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { getConnectBrowserSession } from "@/lib/growth/connect-sessions-repository"; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + const { id } = await context.params; + const session = await getConnectBrowserSession(id); + if (!session) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + + return NextResponse.json({ + status: session.status, + lastError: session.lastError, + platform: session.platform, + }); +} diff --git a/src/app/api/admin/growth/connect/config/route.ts b/src/app/api/admin/growth/connect/config/route.ts new file mode 100644 index 0000000..ac91b26 --- /dev/null +++ b/src/app/api/admin/growth/connect/config/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { resolveGrowthConnect } from "@/lib/growth/connect-routing"; + +export async function GET(request: Request) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + const { mode, viewerBaseUrl } = await resolveGrowthConnect(request); + return NextResponse.json({ mode, viewerBaseUrl }); +} diff --git a/src/app/api/admin/growth/connect/dev/health/route.ts b/src/app/api/admin/growth/connect/dev/health/route.ts new file mode 100644 index 0000000..02b2c8e --- /dev/null +++ b/src/app/api/admin/growth/connect/dev/health/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { + isDevConnectRuntimeEnabled, + isDevConnectRuntimeReady, +} from "@/lib/growth/dev-connect-runtime"; + +export async function GET() { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + if (!isDevConnectRuntimeEnabled()) { + return NextResponse.json({ ok: false, error: "Not in dev mode" }, { status: 404 }); + } + + const ready = await isDevConnectRuntimeReady(); + if (!ready) { + return NextResponse.json( + { + ok: false, + error: + "Playwright is not installed. Run: npm install -D playwright && npx playwright install chromium", + }, + { status: 503 }, + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/growth/connect/dev/v1/embed/route.ts b/src/app/api/admin/growth/connect/dev/v1/embed/route.ts new file mode 100644 index 0000000..68a9ab5 --- /dev/null +++ b/src/app/api/admin/growth/connect/dev/v1/embed/route.ts @@ -0,0 +1,19 @@ +import { connectEmbedHtml } from "@/lib/growth/connect-embed-html"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { isDevConnectRuntimeEnabled } from "@/lib/growth/dev-connect-runtime"; + +export async function GET() { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + if (!isDevConnectRuntimeEnabled()) { + return new Response("Not found", { status: 404 }); + } + + return new Response(connectEmbedHtml(), { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/admin/growth/connect/dev/v1/frame/[id]/route.ts b/src/app/api/admin/growth/connect/dev/v1/frame/[id]/route.ts new file mode 100644 index 0000000..4d12fed --- /dev/null +++ b/src/app/api/admin/growth/connect/dev/v1/frame/[id]/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { + getDevConnectFrame, + isDevConnectRuntimeEnabled, +} from "@/lib/growth/dev-connect-runtime"; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + if (!isDevConnectRuntimeEnabled()) { + return new NextResponse(null, { status: 404 }); + } + + const { id } = await context.params; + const frame = await getDevConnectFrame(id); + if (!frame) { + return new NextResponse(null, { status: 204 }); + } + + return new Response(new Uint8Array(frame), { + headers: { + "Content-Type": "image/jpeg", + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/admin/growth/connect/dev/v1/input/[id]/route.ts b/src/app/api/admin/growth/connect/dev/v1/input/[id]/route.ts new file mode 100644 index 0000000..41216fe --- /dev/null +++ b/src/app/api/admin/growth/connect/dev/v1/input/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { + isDevConnectRuntimeEnabled, + sendDevConnectInput, +} from "@/lib/growth/dev-connect-runtime"; + +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + if (!isDevConnectRuntimeEnabled()) { + return NextResponse.json({ error: "Not available" }, { status: 404 }); + } + + const { id } = await context.params; + const body = (await request.json()) as { + type?: string; + x?: number; + y?: number; + key?: string; + }; + + try { + await sendDevConnectInput(id, { + type: body.type ?? "", + x: body.x, + y: body.y, + key: body.key, + }); + return NextResponse.json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Input failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/connect/dev/v1/sessions/route.ts b/src/app/api/admin/growth/connect/dev/v1/sessions/route.ts new file mode 100644 index 0000000..ce37b7c --- /dev/null +++ b/src/app/api/admin/growth/connect/dev/v1/sessions/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { verifyGrowthConnectToken } from "@/lib/growth/connect-token"; +import { + isDevConnectRuntimeEnabled, + startDevConnectSession, +} from "@/lib/growth/dev-connect-runtime"; + +export async function POST(request: Request) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + if (!isDevConnectRuntimeEnabled()) { + return NextResponse.json({ error: "Not available" }, { status: 404 }); + } + + try { + const body = (await request.json()) as { token?: string }; + if (!body.token?.trim()) { + return NextResponse.json({ error: "token required" }, { status: 400 }); + } + + const platform = await verifyGrowthConnectToken(body.token.trim()); + if (!platform) { + return NextResponse.json( + { error: "Invalid or expired connect token" }, + { status: 401 }, + ); + } + + const { sessionId, interaction } = await startDevConnectSession(platform); + + return NextResponse.json({ + sessionId, + interaction, + embedUrl: `/api/admin/growth/connect/dev/v1/embed?session=${sessionId}`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Session failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/connect/dev/v1/status/[id]/route.ts b/src/app/api/admin/growth/connect/dev/v1/status/[id]/route.ts new file mode 100644 index 0000000..1141f53 --- /dev/null +++ b/src/app/api/admin/growth/connect/dev/v1/status/[id]/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; +import { + getDevConnectStatus, + isDevConnectRuntimeEnabled, +} from "@/lib/growth/dev-connect-runtime"; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + if (!isDevConnectRuntimeEnabled()) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + + const { id } = await context.params; + const session = getDevConnectStatus(id); + if (!session) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + + return NextResponse.json(session); +} diff --git a/src/app/api/admin/growth/connect/handshake/route.ts b/src/app/api/admin/growth/connect/handshake/route.ts new file mode 100644 index 0000000..2802026 --- /dev/null +++ b/src/app/api/admin/growth/connect/handshake/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { mintGrowthConnectToken } from "@/lib/growth/connect-token"; +import { resolveGrowthConnect } from "@/lib/growth/connect-routing"; +import { growthPlatforms, type GrowthPlatform } from "@/lib/growth/types"; + +export async function POST(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as { platform?: GrowthPlatform }; + if (!body.platform || !growthPlatforms.includes(body.platform)) { + return NextResponse.json({ error: "Invalid platform" }, { status: 400 }); + } + + const { token, expiresAt } = await mintGrowthConnectToken(body.platform); + const { mode, viewerBaseUrl } = await resolveGrowthConnect(request); + + return NextResponse.json({ + token, + expiresAt, + viewerBaseUrl, + connectMode: mode, + platform: body.platform, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Handshake failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/connect/relay/[[...path]]/route.ts b/src/app/api/admin/growth/connect/relay/[[...path]]/route.ts new file mode 100644 index 0000000..1888985 --- /dev/null +++ b/src/app/api/admin/growth/connect/relay/[[...path]]/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { requireGrowthConnectAdmin } from "@/lib/growth/connect-admin"; + +function remoteBase(): string | null { + const base = process.env.GROWTH_CONNECT_VIEWER_BASE?.trim(); + return base ? base.replace(/\/$/, "") : null; +} + +async function proxy(request: Request, pathSegments: string[]) { + const auth = await requireGrowthConnectAdmin(); + if (!auth.ok) return auth.response; + + const base = remoteBase(); + if (!base) { + return NextResponse.json({ error: "Relay not configured" }, { status: 404 }); + } + + const path = pathSegments.join("/"); + const url = new URL(request.url); + const target = `${base}/${path}${url.search}`; + + const headers = new Headers(); + const contentType = request.headers.get("content-type"); + if (contentType) headers.set("Content-Type", contentType); + + const init: RequestInit = { + method: request.method, + headers, + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = await request.arrayBuffer(); + } + + const res = await fetch(target, init); + const outHeaders = new Headers(); + const resType = res.headers.get("Content-Type"); + if (resType) outHeaders.set("Content-Type", resType); + outHeaders.set("Cache-Control", "no-store"); + + return new Response(res.body, { status: res.status, headers: outHeaders }); +} + +export async function GET( + request: Request, + context: { params: Promise<{ path?: string[] }> }, +) { + const { path = [] } = await context.params; + return proxy(request, path); +} + +export async function POST( + request: Request, + context: { params: Promise<{ path?: string[] }> }, +) { + const { path = [] } = await context.params; + return proxy(request, path); +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +} diff --git a/src/app/api/admin/growth/connect/validate/route.ts b/src/app/api/admin/growth/connect/validate/route.ts new file mode 100644 index 0000000..2064372 --- /dev/null +++ b/src/app/api/admin/growth/connect/validate/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { authorizeGrowthApi } from "@/lib/growth/auth"; +import { verifyGrowthConnectToken } from "@/lib/growth/connect-token"; + +/** Used by the local connect server to authorize a browser session. */ +export async function POST(request: Request) { + const auth = await authorizeGrowthApi(request); + if (!auth.authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as { token?: string }; + if (!body.token?.trim()) { + return NextResponse.json({ error: "token required" }, { status: 400 }); + } + + const platform = await verifyGrowthConnectToken(body.token.trim()); + if (!platform) { + return NextResponse.json({ valid: false }, { status: 401 }); + } + + return NextResponse.json({ valid: true, platform }); + } catch (error) { + const message = error instanceof Error ? error.message : "Validate failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/connector/credentials/route.ts b/src/app/api/admin/growth/connector/credentials/route.ts new file mode 100644 index 0000000..febf363 --- /dev/null +++ b/src/app/api/admin/growth/connector/credentials/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { authorizeConnectorSecret } from "@/lib/growth/connector-auth"; +import { getPlatformSession } from "@/lib/growth/repository"; +import { growthPlatforms, type GrowthPlatform } from "@/lib/growth/types"; + +/** + * Returns decrypted session cookies for the local connector only. + * Requires GROWTH_CONNECTOR_SECRET (not an admin browser session). + */ +export async function GET(request: Request) { + if (!authorizeConnectorSecret(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const platform = new URL(request.url).searchParams.get( + "platform", + ) as GrowthPlatform | null; + if (!platform || !growthPlatforms.includes(platform)) { + return NextResponse.json({ error: "Invalid platform" }, { status: 400 }); + } + + try { + const session = await getPlatformSession(platform); + if (!session) { + return NextResponse.json({ error: "No credentials stored" }, { status: 404 }); + } + return NextResponse.json({ + platform, + cookies: session.cookies, + userAgent: session.userAgent, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load credentials"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/connector/ingest/route.ts b/src/app/api/admin/growth/connector/ingest/route.ts new file mode 100644 index 0000000..a3a79ee --- /dev/null +++ b/src/app/api/admin/growth/connector/ingest/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { authorizeGrowthApi } from "@/lib/growth/auth"; +import { ingestSignal } from "@/lib/growth/repository"; +import { growthPlatforms, type IngestSignalInput } from "@/lib/growth/types"; + +/** Batch ingest for a local connector using GROWTH_CONNECTOR_SECRET or admin session. */ +export async function POST(request: Request) { + const auth = await authorizeGrowthApi(request); + if (!auth.authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as { signals?: IngestSignalInput[] }; + if (!body.signals?.length) { + return NextResponse.json({ error: "signals array required" }, { status: 400 }); + } + + const results = []; + for (const signal of body.signals) { + if (!signal.platform || !growthPlatforms.includes(signal.platform)) { + continue; + } + if (!signal.authorName?.trim() || !signal.bodyText?.trim()) { + continue; + } + results.push( + await ingestSignal({ + ...signal, + authorName: signal.authorName.trim(), + bodyText: signal.bodyText.trim(), + }), + ); + } + + return NextResponse.json({ ingested: results.length, results }); + } catch (error) { + const message = error instanceof Error ? error.message : "Batch ingest failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/credentials/route.ts b/src/app/api/admin/growth/credentials/route.ts new file mode 100644 index 0000000..edf17d8 --- /dev/null +++ b/src/app/api/admin/growth/credentials/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { authorizeGrowthApi } from "@/lib/growth/auth"; +import { + listCredentialStatus, + savePlatformCredentials, +} from "@/lib/growth/repository"; +import { growthPlatforms, type GrowthPlatform } from "@/lib/growth/types"; + +export async function GET(request: Request) { + const auth = await authorizeGrowthApi(request); + if (!auth.authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const platforms = await listCredentialStatus(); + return NextResponse.json({ platforms }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load credentials"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + const auth = await authorizeGrowthApi(request); + if (!auth.authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as { + platform?: GrowthPlatform; + cookies?: unknown; + userAgent?: string; + displayLabel?: string; + }; + + if (!body.platform || !growthPlatforms.includes(body.platform)) { + return NextResponse.json({ error: "Invalid platform" }, { status: 400 }); + } + if (!body.cookies) { + return NextResponse.json({ error: "cookies are required" }, { status: 400 }); + } + + await savePlatformCredentials({ + platform: body.platform, + cookies: body.cookies, + userAgent: body.userAgent, + displayLabel: body.displayLabel, + }); + return NextResponse.json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Save failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/drafts/[id]/publish/route.ts b/src/app/api/admin/growth/drafts/[id]/publish/route.ts new file mode 100644 index 0000000..9c6558d --- /dev/null +++ b/src/app/api/admin/growth/drafts/[id]/publish/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { publishDraftReply } from "@/lib/growth/repository"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(request: Request, { params }: Params) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + let bodyText = ""; + try { + const json = (await request.json()) as { bodyText?: string }; + bodyText = json.bodyText?.trim() ?? ""; + } catch { + /* empty body β†’ use stored draft text */ + } + + try { + const draft = await publishDraftReply(id, bodyText); + return NextResponse.json({ draft }); + } catch (error) { + const message = error instanceof Error ? error.message : "Publish failed"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/api/admin/growth/drafts/[id]/regenerate/route.ts b/src/app/api/admin/growth/drafts/[id]/regenerate/route.ts new file mode 100644 index 0000000..18dbbc5 --- /dev/null +++ b/src/app/api/admin/growth/drafts/[id]/regenerate/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { regenerateDraftWithAi } from "@/lib/growth/repository"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(_request: Request, { params }: Params) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + try { + const result = await regenerateDraftWithAi(id); + if (!result) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Regenerate failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/drafts/[id]/route.ts b/src/app/api/admin/growth/drafts/[id]/route.ts new file mode 100644 index 0000000..9bb65a9 --- /dev/null +++ b/src/app/api/admin/growth/drafts/[id]/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { updateDraft } from "@/lib/growth/repository"; +import { draftStatuses, type DraftStatus } from "@/lib/growth/types"; + +type Params = { params: Promise<{ id: string }> }; + +export async function PATCH(request: Request, { params }: Params) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = (await request.json()) as { + bodyText?: string; + imageNotes?: string | null; + status?: DraftStatus; + reviewNotes?: string | null; + markPublished?: boolean; + publishError?: string | null; + }; + + if (body.status && !draftStatuses.includes(body.status)) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }); + } + + try { + const draft = await updateDraft(id, body); + if (!draft) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json({ draft }); + } catch (error) { + const message = error instanceof Error ? error.message : "Update failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/harvest/route.ts b/src/app/api/admin/growth/harvest/route.ts new file mode 100644 index 0000000..eca8fde --- /dev/null +++ b/src/app/api/admin/growth/harvest/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { + encodeHarvestEvent, + progressLine, + type HarvestProgressEvent, +} from "@/lib/growth/harvest-progress"; +import { runPlatformHarvest } from "@/lib/growth/run-harvest"; +import { growthPlatforms, type GrowthPlatform } from "@/lib/growth/types"; + +function wantsStream(request: Request): boolean { + const accept = request.headers.get("accept") ?? ""; + if (accept.includes("application/x-ndjson")) return true; + const url = new URL(request.url); + return url.searchParams.get("stream") === "1"; +} + +export async function POST(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let platform: GrowthPlatform | undefined; + try { + const body = (await request.json()) as { platform?: GrowthPlatform }; + platform = body.platform; + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + if (!platform || !growthPlatforms.includes(platform)) { + return NextResponse.json({ error: "Invalid platform" }, { status: 400 }); + } + + if (wantsStream(request)) { + const stream = new ReadableStream({ + async start(controller) { + const emit = (event: HarvestProgressEvent) => { + controller.enqueue(encodeHarvestEvent(event)); + }; + + try { + const result = await runPlatformHarvest(platform!, (phase, message) => { + emit(progressLine(phase, message)); + }); + emit({ + type: "complete", + platform: platform!, + captured: result.captured, + ingested: result.ingested, + at: new Date().toISOString(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Harvest failed"; + emit({ + type: "error", + message, + at: new Date().toISOString(), + }); + } + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "application/x-ndjson; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + } + + try { + const result = await runPlatformHarvest(platform); + return NextResponse.json({ + ok: true, + platform, + ...result, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Harvest failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/icp/route.ts b/src/app/api/admin/growth/icp/route.ts new file mode 100644 index 0000000..4ca4672 --- /dev/null +++ b/src/app/api/admin/growth/icp/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { getIcpConfig, saveIcpConfig } from "@/lib/growth/repository"; +import type { IcpConfig } from "@/lib/growth/types"; + +export async function GET() { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const config = await getIcpConfig(); + return NextResponse.json({ config }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load ICP"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const body = (await request.json()) as { config?: IcpConfig }; + if (!body.config) { + return NextResponse.json({ error: "config is required" }, { status: 400 }); + } + await saveIcpConfig(body.config); + return NextResponse.json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Save failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/inbox/route.ts b/src/app/api/admin/growth/inbox/route.ts new file mode 100644 index 0000000..972a768 --- /dev/null +++ b/src/app/api/admin/growth/inbox/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { authorizeGrowthApi } from "@/lib/growth/auth"; +import { listInbox } from "@/lib/growth/repository"; +import { growthPlatforms, type GrowthPlatform } from "@/lib/growth/types"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const auth = await authorizeGrowthApi(request); + if (!auth.authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const { searchParams } = new URL(request.url); + const minScore = Number.parseInt(searchParams.get("minScore") ?? "", 10); + const platformParam = searchParams.get("platform"); + const platform = + platformParam && growthPlatforms.includes(platformParam as GrowthPlatform) + ? (platformParam as GrowthPlatform) + : null; + const inbox = await listInbox({ + minScore: Number.isFinite(minScore) ? minScore : 0, + platform, + }); + return NextResponse.json(inbox); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load inbox"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/leads/[id]/generate-draft/route.ts b/src/app/api/admin/growth/leads/[id]/generate-draft/route.ts new file mode 100644 index 0000000..d25d9d4 --- /dev/null +++ b/src/app/api/admin/growth/leads/[id]/generate-draft/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { generateDraftForLead } from "@/lib/growth/repository"; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(_request: Request, { params }: Params) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + try { + const result = await generateDraftForLead(id); + if (!result) { + return NextResponse.json({ error: "Lead not found" }, { status: 404 }); + } + return NextResponse.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Generate failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/leads/[id]/route.ts b/src/app/api/admin/growth/leads/[id]/route.ts new file mode 100644 index 0000000..f316daa --- /dev/null +++ b/src/app/api/admin/growth/leads/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { updateLead } from "@/lib/growth/repository"; +import { pipelineStatuses, type PipelineStatus } from "@/lib/growth/types"; + +type Params = { params: Promise<{ id: string }> }; + +export async function PATCH(request: Request, { params }: Params) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = (await request.json()) as { + pipelineStatus?: PipelineStatus; + feedbackNote?: string | null; + }; + + if ( + body.pipelineStatus && + !pipelineStatuses.includes(body.pipelineStatus) + ) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }); + } + + try { + const lead = await updateLead(id, body); + if (!lead) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json({ lead }); + } catch (error) { + const message = error instanceof Error ? error.message : "Update failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/leads/route.ts b/src/app/api/admin/growth/leads/route.ts new file mode 100644 index 0000000..b375fd9 --- /dev/null +++ b/src/app/api/admin/growth/leads/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { authorizeGrowthApi } from "@/lib/growth/auth"; +import { listLeads } from "@/lib/growth/repository"; +import { growthPlatforms, type GrowthPlatform } from "@/lib/growth/types"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const auth = await authorizeGrowthApi(request); + if (!auth.authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const { searchParams } = new URL(request.url); + const minScore = Number.parseInt(searchParams.get("minScore") ?? "", 10); + const maxScore = Number.parseInt(searchParams.get("maxScore") ?? "", 10); + const platformParam = searchParams.get("platform"); + const platform = + platformParam && growthPlatforms.includes(platformParam as GrowthPlatform) + ? (platformParam as GrowthPlatform) + : null; + const leads = await listLeads({ + minScore: Number.isFinite(minScore) ? minScore : 0, + maxScore: Number.isFinite(maxScore) ? maxScore : 100, + platform, + }); + return NextResponse.json({ leads }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to list leads"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/sessions/health/route.ts b/src/app/api/admin/growth/sessions/health/route.ts new file mode 100644 index 0000000..2cca990 --- /dev/null +++ b/src/app/api/admin/growth/sessions/health/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { authorizeGrowthApi } from "@/lib/growth/auth"; +import { reportSessionHealth } from "@/lib/growth/repository"; +import { growthPlatforms, sessionStatuses, type SessionStatus } from "@/lib/growth/types"; + +export async function POST(request: Request) { + const auth = await authorizeGrowthApi(request); + if (!auth.authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as { + platform?: string; + sessionStatus?: SessionStatus; + lastError?: string | null; + connectedAccountLabel?: string | null; + }; + + if ( + !body.platform || + !growthPlatforms.includes(body.platform as (typeof growthPlatforms)[number]) + ) { + return NextResponse.json({ error: "Invalid platform" }, { status: 400 }); + } + if ( + !body.sessionStatus || + !sessionStatuses.includes(body.sessionStatus) + ) { + return NextResponse.json({ error: "Invalid sessionStatus" }, { status: 400 }); + } + + await reportSessionHealth({ + platform: body.platform as (typeof growthPlatforms)[number], + sessionStatus: body.sessionStatus, + lastError: body.lastError, + connectedAccountLabel: body.connectedAccountLabel, + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Health update failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/sessions/route.ts b/src/app/api/admin/growth/sessions/route.ts new file mode 100644 index 0000000..3f2bbec --- /dev/null +++ b/src/app/api/admin/growth/sessions/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { listCredentialStatus } from "@/lib/growth/repository"; + +export async function GET() { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const platforms = await listCredentialStatus(); + return NextResponse.json({ platforms }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load sessions"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/growth/signals/route.ts b/src/app/api/admin/growth/signals/route.ts new file mode 100644 index 0000000..47952a6 --- /dev/null +++ b/src/app/api/admin/growth/signals/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { authorizeGrowthApi } from "@/lib/growth/auth"; +import { ingestSignal } from "@/lib/growth/repository"; +import { growthPlatforms, type IngestSignalInput } from "@/lib/growth/types"; + +export async function POST(request: Request) { + const auth = await authorizeGrowthApi(request); + if (!auth.authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as IngestSignalInput; + if (!body.platform || !growthPlatforms.includes(body.platform)) { + return NextResponse.json({ error: "Invalid platform" }, { status: 400 }); + } + if (!body.authorName?.trim() || !body.bodyText?.trim()) { + return NextResponse.json( + { error: "authorName and bodyText are required" }, + { status: 400 }, + ); + } + + const result = await ingestSignal({ + ...body, + authorName: body.authorName.trim(), + bodyText: body.bodyText.trim(), + }); + return NextResponse.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Ingest failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/admin/kpay/balance/route.ts b/src/app/api/admin/kpay/balance/route.ts new file mode 100644 index 0000000..5a3b240 --- /dev/null +++ b/src/app/api/admin/kpay/balance/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { + isKPayTestMode, + type PaymentEnvironment, +} from "@/lib/kpay/environment"; +import { fetchWalletSnapshot } from "@/lib/kpay/wallets"; + +function parseEnvironment(value: string | null): PaymentEnvironment | null { + if (value === "production" || value === "test") return value; + return null; +} + +export async function GET(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const environment = + parseEnvironment(searchParams.get("environment")) ?? + (isKPayTestMode() ? "test" : "production"); + + const snapshot = await fetchWalletSnapshot(environment); + return NextResponse.json(snapshot); +} diff --git a/src/app/api/admin/kpay/withdraw/route.ts b/src/app/api/admin/kpay/withdraw/route.ts new file mode 100644 index 0000000..1c5f00c --- /dev/null +++ b/src/app/api/admin/kpay/withdraw/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { initWithdraw } from "@/lib/kpay/client"; + +export async function POST(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as { + amount?: number; + provider?: string; + phoneNumber?: string; + description?: string; + }; + + const amount = Number(body.amount); + const provider = body.provider?.trim() ?? ""; + const phoneNumber = body.phoneNumber?.replace(/\D/g, "") ?? ""; + + if (!Number.isFinite(amount) || amount <= 0) { + return NextResponse.json({ error: "Invalid amount" }, { status: 400 }); + } + if (!provider || !phoneNumber) { + return NextResponse.json( + { error: "Provider and phone number are required" }, + { status: 400 }, + ); + } + + const withdrawal = await initWithdraw({ + amount, + provider, + phoneNumber, + externalId: `WD-${crypto.randomUUID()}`, + description: body.description?.trim() || "Stackwise wallet withdrawal", + }); + + return NextResponse.json({ withdrawal }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Withdrawal failed" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/admin/login/otp/send/route.ts b/src/app/api/admin/login/otp/send/route.ts new file mode 100644 index 0000000..81cd3f3 --- /dev/null +++ b/src/app/api/admin/login/otp/send/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { OtpLockedError } from "@/lib/otp/lockout"; +import { OtpThrottleError } from "@/lib/otp/throttle"; +import { sendAdminLoginOtp } from "@/lib/otp/admin"; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as { email?: string }; + const email = body.email?.trim() ?? ""; + if (!email) { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + const result = await sendAdminLoginOtp(email); + return NextResponse.json(result); + } catch (error) { + if (error instanceof OtpLockedError || error instanceof OtpThrottleError) { + return NextResponse.json({ error: error.message }, { status: 429 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unable to send code" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/login/otp/verify/route.ts b/src/app/api/admin/login/otp/verify/route.ts new file mode 100644 index 0000000..f04095d --- /dev/null +++ b/src/app/api/admin/login/otp/verify/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { createSessionToken, setSessionCookie } from "@/lib/auth/session"; +import { OtpLockedError } from "@/lib/otp/lockout"; +import { verifyAdminLoginOtp } from "@/lib/otp/admin"; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as { email?: string; otp?: string }; + const email = body.email?.trim() ?? ""; + const otp = body.otp?.trim() ?? ""; + + if (!email || !otp) { + return NextResponse.json( + { error: "Email and code are required" }, + { status: 400 }, + ); + } + + const verified = await verifyAdminLoginOtp(email, otp); + const token = await createSessionToken(verified.email); + await setSessionCookie(token); + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof OtpLockedError) { + return NextResponse.json({ error: error.message }, { status: 429 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : "Invalid code" }, + { status: 401 }, + ); + } +} diff --git a/src/app/api/admin/logout/route.ts b/src/app/api/admin/logout/route.ts new file mode 100644 index 0000000..a723add --- /dev/null +++ b/src/app/api/admin/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { clearSessionCookie } from "@/lib/auth/session"; + +export async function POST() { + await clearSessionCookie(); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/maintenance-subscriptions/[id]/send/route.ts b/src/app/api/admin/maintenance-subscriptions/[id]/send/route.ts new file mode 100644 index 0000000..d298422 --- /dev/null +++ b/src/app/api/admin/maintenance-subscriptions/[id]/send/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { + getMaintenanceSubscriptionById, + saveMaintenanceSubscription, +} from "@/lib/db/maintenance-subscriptions"; +import { sendMaintenanceSubscriptionEmail } from "@/lib/emails/subscriptions"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const subscription = await getMaintenanceSubscriptionById(id); + if (!subscription) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + await sendMaintenanceSubscriptionEmail(subscription); + const updated = await saveMaintenanceSubscription({ + ...subscription, + status: subscription.status === "DRAFT" ? "SENT" : subscription.status, + sentAt: Date.now(), + updatedAt: Date.now(), + }); + + return NextResponse.json({ subscription: updated }); +} diff --git a/src/app/api/admin/maintenance-subscriptions/route.ts b/src/app/api/admin/maintenance-subscriptions/route.ts new file mode 100644 index 0000000..d17aa3b --- /dev/null +++ b/src/app/api/admin/maintenance-subscriptions/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { sendMaintenanceSubscriptionEmail } from "@/lib/emails/subscriptions"; +import { + listMaintenanceSubscriptions, + saveMaintenanceSubscription, +} from "@/lib/db/maintenance-subscriptions"; +import { + createMaintenanceSubscription, + enrichSubscriptionUsd, +} from "@/lib/subscriptions/actions"; +import type { SubscriptionInterval } from "@/lib/subscriptions/types"; +import { getPaystackChargeCurrencies } from "@/lib/paystack/currencies"; +import { isPaystackConfigured } from "@/lib/paystack/environment"; + +export async function GET() { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const subscriptions = await listMaintenanceSubscriptions(); + return NextResponse.json({ subscriptions }); +} + +export async function POST(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isPaystackConfigured()) { + return NextResponse.json( + { error: "Configure PAYSTACK_SECRET_KEY before creating subscriptions" }, + { status: 400 }, + ); + } + + try { + const body = (await request.json()) as { + customerName?: string; + customerEmail?: string; + currency?: string; + amountMajor?: number; + interval?: SubscriptionInterval; + description?: string; + sendEmail?: boolean; + }; + + const allowed = getPaystackChargeCurrencies(); + const currency = (body.currency ?? allowed[0] ?? "KES").toUpperCase(); + + const input = await enrichSubscriptionUsd({ + customerName: body.customerName?.trim() ?? "", + customerEmail: body.customerEmail?.trim() ?? "", + currency, + amountMajor: Number(body.amountMajor), + interval: body.interval ?? "monthly", + description: body.description?.trim() ?? "", + }); + + if (!input.customerName || !input.customerEmail) { + return NextResponse.json( + { error: "Customer name and email are required" }, + { status: 400 }, + ); + } + if (!input.description) { + return NextResponse.json({ error: "Description is required" }, { status: 400 }); + } + + let subscription = await createMaintenanceSubscription(input); + + if (body.sendEmail) { + await sendMaintenanceSubscriptionEmail(subscription); + subscription = await saveMaintenanceSubscription({ + ...subscription, + status: "SENT", + sentAt: Date.now(), + updatedAt: Date.now(), + }); + } + + return NextResponse.json({ subscription }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create subscription" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/admin/payment-links/[id]/record-payment/route.ts b/src/app/api/admin/payment-links/[id]/record-payment/route.ts new file mode 100644 index 0000000..ef652de --- /dev/null +++ b/src/app/api/admin/payment-links/[id]/record-payment/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { + isManualPaymentMethod, + type ManualPaymentMethod, +} from "@/lib/payments/manual-payment-methods"; +import { recordManualPayment } from "@/lib/payments/manual-payment"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + let body: { + paymentMethod?: string; + amountReceivedUsd?: number; + amountReceivedLocal?: number; + collectedAt?: string; + paymentReference?: string; + paymentNotes?: string; + sendReceipt?: boolean; + }; + + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.paymentMethod || !isManualPaymentMethod(body.paymentMethod)) { + return NextResponse.json({ error: "Invalid payment method" }, { status: 400 }); + } + + if (!body.paymentReference?.trim()) { + return NextResponse.json({ error: "Payment reference is required" }, { status: 400 }); + } + + if (!body.collectedAt) { + return NextResponse.json({ error: "Collection time is required" }, { status: 400 }); + } + + const collectedAt = new Date(body.collectedAt).getTime(); + if (!Number.isFinite(collectedAt)) { + return NextResponse.json({ error: "Invalid collection time" }, { status: 400 }); + } + + try { + const link = await recordManualPayment( + id, + { + paymentMethod: body.paymentMethod as ManualPaymentMethod, + amountReceivedUsd: Number(body.amountReceivedUsd), + amountReceivedLocal: Number(body.amountReceivedLocal), + collectedAt, + paymentReference: body.paymentReference, + paymentNotes: body.paymentNotes, + sendReceipt: body.sendReceipt, + }, + session.email, + ); + return NextResponse.json({ link }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to record payment"; + const status = message.includes("not found") + ? 404 + : message.includes("already marked") + ? 409 + : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/admin/payment-links/[id]/resend-receipt/route.ts b/src/app/api/admin/payment-links/[id]/resend-receipt/route.ts new file mode 100644 index 0000000..878ff26 --- /dev/null +++ b/src/app/api/admin/payment-links/[id]/resend-receipt/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { resendPaymentReceipt } from "@/lib/payments/manual-payment"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + try { + const link = await resendPaymentReceipt(id); + return NextResponse.json({ link }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to send receipt"; + const status = message.includes("not found") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/admin/payment-links/[id]/route.ts b/src/app/api/admin/payment-links/[id]/route.ts new file mode 100644 index 0000000..8fc8cc0 --- /dev/null +++ b/src/app/api/admin/payment-links/[id]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { getPaymentLinkById } from "@/lib/db/payment-links"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const link = await getPaymentLinkById(id); + if (!link) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + return NextResponse.json({ link }); +} diff --git a/src/app/api/admin/payment-links/[id]/send/route.ts b/src/app/api/admin/payment-links/[id]/send/route.ts new file mode 100644 index 0000000..1f9708e --- /dev/null +++ b/src/app/api/admin/payment-links/[id]/send/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { getPaymentLinkById, savePaymentLink } from "@/lib/db/payment-links"; +import { sendPaymentLinkEmail } from "@/lib/emails/payments"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const link = await getPaymentLinkById(id); + if (!link) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + try { + await sendPaymentLinkEmail(link); + const updated = await savePaymentLink({ + ...link, + status: link.status === "DRAFT" ? "SENT" : link.status, + sentAt: link.sentAt ?? Date.now(), + updatedAt: Date.now(), + }); + return NextResponse.json({ link: updated }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to send email" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/payment-links/route.ts b/src/app/api/admin/payment-links/route.ts new file mode 100644 index 0000000..b071d9a --- /dev/null +++ b/src/app/api/admin/payment-links/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { listPaymentLinks } from "@/lib/db/payment-links"; +import { createPaymentLink } from "@/lib/payments/actions"; +import { sendPaymentLinkEmail } from "@/lib/emails/payments"; +import type { LineItem } from "@/lib/payments/types"; +import type { PaymentMethodsOption } from "@/lib/payments/payment-methods"; + +export async function GET() { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const links = await listPaymentLinks(); + return NextResponse.json({ links }); +} + +export async function POST(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as { + customerName?: string; + customerEmail?: string; + currency?: string; + lineItems?: LineItem[]; + notes?: string; + sendEmail?: boolean; + allowedPaymentMethods?: PaymentMethodsOption; + }; + + if (!body.customerName?.trim() || !body.customerEmail?.trim()) { + return NextResponse.json( + { error: "Customer name and email are required" }, + { status: 400 }, + ); + } + if (!body.lineItems?.length) { + return NextResponse.json( + { error: "At least one service is required" }, + { status: 400 }, + ); + } + + const link = await createPaymentLink({ + customerName: body.customerName, + customerEmail: body.customerEmail, + currency: body.currency ?? process.env.DEFAULT_PAYMENT_CURRENCY ?? "XAF", + lineItems: body.lineItems, + notes: body.notes, + allowedPaymentMethods: body.allowedPaymentMethods, + }); + + let saved = link; + if (body.sendEmail) { + await sendPaymentLinkEmail(link); + const { savePaymentLink } = await import("@/lib/db/payment-links"); + saved = await savePaymentLink({ + ...link, + status: "SENT", + sentAt: Date.now(), + updatedAt: Date.now(), + }); + } + + return NextResponse.json({ link: saved }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create link" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/contracts/[slug]/accept/route.ts b/src/app/api/contracts/[slug]/accept/route.ts new file mode 100644 index 0000000..f19a89e --- /dev/null +++ b/src/app/api/contracts/[slug]/accept/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { acceptContractEngagement } from "@/lib/contracts/actions"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + const body = (await request.json()) as { acceptedByName?: string }; + + try { + const engagement = await acceptContractEngagement(slug, body.acceptedByName ?? ""); + return NextResponse.json({ ok: true, status: engagement.status }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed"; + const status = message.includes("not found") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/contracts/[slug]/feedback/route.ts b/src/app/api/contracts/[slug]/feedback/route.ts new file mode 100644 index 0000000..febeeaf --- /dev/null +++ b/src/app/api/contracts/[slug]/feedback/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { submitClientContractFeedback } from "@/lib/contracts/actions"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + const body = (await request.json()) as { message?: string }; + + try { + const engagement = await submitClientContractFeedback(slug, body.message ?? ""); + return NextResponse.json({ ok: true, status: engagement.status }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed"; + const status = message.includes("not found") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts new file mode 100644 index 0000000..8bd8a73 --- /dev/null +++ b/src/app/api/exchange-rate/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { convertUsdToCurrency } from "@/lib/currency"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const from = searchParams.get("from") ?? "USD"; + const to = searchParams.get("to") ?? "XAF"; + const amount = Number.parseFloat(searchParams.get("amount") ?? "1"); + + if (!Number.isFinite(amount) || amount <= 0) { + return NextResponse.json({ error: "Invalid amount" }, { status: 400 }); + } + + try { + const { amount: converted, rate } = await convertUsdToCurrency(amount, to); + return NextResponse.json({ + from, + to, + rate, + amount, + converted, + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Conversion failed" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/pay/[slug]/init/route.ts b/src/app/api/pay/[slug]/init/route.ts new file mode 100644 index 0000000..3923969 --- /dev/null +++ b/src/app/api/pay/[slug]/init/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { recordPaymentAudit } from "@/lib/db/payment-audit"; +import type { CheckoutPaymentMethod } from "@/lib/kpay/buildGatewayInit"; +import { + initiateKPayPayment, + initiatePaystackPayment, +} from "@/lib/payments/actions"; +import { cardCheckoutProcessor } from "@/lib/payments/processor"; + +function parseMethod(value: unknown): CheckoutPaymentMethod { + if (value === "MOBILE_MONEY") return "MOBILE_MONEY"; + return "CARD"; +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + + try { + const body = (await request.json().catch(() => ({}))) as { + method?: unknown; + }; + const method = parseMethod(body.method); + const processor = method === "CARD" ? cardCheckoutProcessor() : "KPAY"; + const { link, gatewayUrl } = + processor === "PAYSTACK" + ? await initiatePaystackPayment(slug) + : await initiateKPayPayment(slug, method); + recordPaymentAudit(link.id, "PAYMENT_INIT", request.headers, { + method, + processor, + }); + return NextResponse.json({ gatewayUrl }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Payment initiation failed"; + const status = message.includes("timed out") ? 504 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/pay/subscribe/[slug]/init/route.ts b/src/app/api/pay/subscribe/[slug]/init/route.ts new file mode 100644 index 0000000..f1b649e --- /dev/null +++ b/src/app/api/pay/subscribe/[slug]/init/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { initiateMaintenanceSubscriptionCheckout } from "@/lib/subscriptions/actions"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + + try { + const { gatewayUrl } = await initiateMaintenanceSubscriptionCheckout(slug); + return NextResponse.json({ gatewayUrl }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to start subscription"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/api/webhooks/deposits/route.ts b/src/app/api/webhooks/deposits/route.ts new file mode 100644 index 0000000..9120a06 --- /dev/null +++ b/src/app/api/webhooks/deposits/route.ts @@ -0,0 +1,6 @@ +import { handleKPayWebhookRequest } from "@/lib/kpay/webhook"; + +/** KPay deposit (payment in) notifications β€” payment.initiated, payment.completed, etc. */ +export async function POST(request: Request) { + return handleKPayWebhookRequest(request); +} diff --git a/src/app/api/webhooks/kpay/route.ts b/src/app/api/webhooks/kpay/route.ts new file mode 100644 index 0000000..0841fb8 --- /dev/null +++ b/src/app/api/webhooks/kpay/route.ts @@ -0,0 +1,6 @@ +import { handleKPayWebhookRequest } from "@/lib/kpay/webhook"; + +/** Generic KPay callback β€” receives all event types when specific URLs are not set. */ +export async function POST(request: Request) { + return handleKPayWebhookRequest(request); +} diff --git a/src/app/api/webhooks/payouts/route.ts b/src/app/api/webhooks/payouts/route.ts new file mode 100644 index 0000000..63f4669 --- /dev/null +++ b/src/app/api/webhooks/payouts/route.ts @@ -0,0 +1,6 @@ +import { handleKPayWebhookRequest } from "@/lib/kpay/webhook"; + +/** KPay payout (payment out) notifications. */ +export async function POST(request: Request) { + return handleKPayWebhookRequest(request); +} diff --git a/src/app/api/webhooks/paystack/route.ts b/src/app/api/webhooks/paystack/route.ts new file mode 100644 index 0000000..2001e18 --- /dev/null +++ b/src/app/api/webhooks/paystack/route.ts @@ -0,0 +1,6 @@ +import { handlePaystackWebhookRequest } from "@/lib/paystack/webhook"; + +/** Paystack charge webhook. Dashboard β†’ Settings β†’ API Keys & Webhooks. */ +export async function POST(request: Request) { + return handlePaystackWebhookRequest(request); +} diff --git a/src/app/api/webhooks/refunds/route.ts b/src/app/api/webhooks/refunds/route.ts new file mode 100644 index 0000000..129c01e --- /dev/null +++ b/src/app/api/webhooks/refunds/route.ts @@ -0,0 +1,6 @@ +import { handleKPayWebhookRequest } from "@/lib/kpay/webhook"; + +/** KPay refund notifications. */ +export async function POST(request: Request) { + return handleKPayWebhookRequest(request); +} diff --git a/src/app/c/[slug]/page.tsx b/src/app/c/[slug]/page.tsx new file mode 100644 index 0000000..d7afb9d --- /dev/null +++ b/src/app/c/[slug]/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from "next/navigation"; +import { EngagementPublicPortal } from "@/components/contracts/EngagementPublicPortal"; +import { getContractEngagementBySlug } from "@/lib/db/contracts"; +import { listPaymentLinksForEngagement } from "@/lib/db/payment-links"; +import { isContractPortalPublic } from "@/lib/contracts/types"; + +export default async function PublicContractPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const engagement = await getContractEngagementBySlug(slug); + + if (!engagement || !isContractPortalPublic(engagement.status)) { + notFound(); + } + + const paymentLinks = await listPaymentLinksForEngagement(engagement.id); + + return ; +} diff --git a/src/app/c/layout.tsx b/src/app/c/layout.tsx new file mode 100644 index 0000000..d43f2a2 --- /dev/null +++ b/src/app/c/layout.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; +import { Geist, Geist_Mono } from "next/font/google"; +import { createPageMetadata } from "@/lib/metadata"; +import { ClientProviders } from "@/components/ClientProviders"; +import { SITE_NAME } from "@/lib/content"; +import "../../styles/globals.css"; +import "../../styles/brand-document.css"; +import "../../styles/contract-document.css"; +import "../../styles/print-document.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata = createPageMetadata({ + title: `Agreement | ${SITE_NAME}`, + description: `Review your agreement with ${SITE_NAME}.`, + path: "/c", + noIndex: true, +}); + +export default function ContractPublicLayout({ children }: { children: ReactNode }) { + return ( + + + +
{children}
+
+ + + ); +} diff --git a/src/app/pay/(checkout)/[slug]/page.tsx b/src/app/pay/(checkout)/[slug]/page.tsx new file mode 100644 index 0000000..2db67de --- /dev/null +++ b/src/app/pay/(checkout)/[slug]/page.tsx @@ -0,0 +1,101 @@ +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; +import Mark from "@/components/Mark"; +import { InvoiceView } from "@/components/payments/InvoiceView"; +import { PaymentLegalNotice } from "@/components/payments/PaymentLegalNotice"; +import { PayButton } from "@/components/payments/PayButton"; +import { recordPaymentAudit } from "@/lib/db/payment-audit"; +import { getPaymentLinkBySlug } from "@/lib/db/payment-links"; +import { syncPendingPayment } from "@/lib/payments/actions"; +import { cardCheckoutProcessor } from "@/lib/payments/processor"; +import { SITE_NAME } from "@/lib/content"; + +async function maybeSyncPendingLink(link: Awaited>) { + if (!link || link.status !== "PENDING") return link; + if (!link.paystackReference && !link.kpayPaymentId) return link; + + try { + return await Promise.race([ + syncPendingPayment(link), + new Promise((resolve) => { + setTimeout(() => resolve(link), 8_000); + }), + ]); + } catch { + return link; + } +} + +export default async function PayPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + let link = await getPaymentLinkBySlug(slug); + if (!link) notFound(); + + link = (await maybeSyncPendingLink(link)) ?? link; + + recordPaymentAudit(link.id, "PAY_PAGE_VIEW", await headers()); + + const isPaid = link.status === "PAID"; + const cardProcessor = cardCheckoutProcessor(); + + return ( +
+
+ +
+

{SITE_NAME}

+

Secure payment

+
+
+ + {isPaid ? ( +
+

Payment received

+

+ Thank you, {link.customerName}. Your payment for {link.invoiceNumber} is complete. +

+ + View receipt + +
+ ) : link.status === "FAILED" || link.status === "CANCELLED" ? ( +
+ Previous payment attempt {link.status.toLowerCase()}. You can try again below. +
+ ) : null} + + + + {!isPaid && ( + <> +
+ +
+
+ +
+ + )} + + +
+ ); +} diff --git a/src/app/pay/(checkout)/[slug]/receipt/page.tsx b/src/app/pay/(checkout)/[slug]/receipt/page.tsx new file mode 100644 index 0000000..401a460 --- /dev/null +++ b/src/app/pay/(checkout)/[slug]/receipt/page.tsx @@ -0,0 +1,48 @@ +import { headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { InvoiceView } from "@/components/payments/InvoiceView"; +import { PrintButton } from "@/components/payments/PrintButton"; +import { recordPaymentAudit } from "@/lib/db/payment-audit"; +import { getPaymentLinkBySlug } from "@/lib/db/payment-links"; +import { COMPANY_WEBSITE, COMPANY_WEBSITE_URL } from "@/lib/company"; +import { SITE_NAME } from "@/lib/content"; +import { canAccessReceipt } from "@/lib/payments/receipt-access"; + +export default async function ReceiptPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const link = await getPaymentLinkBySlug(slug); + if (!link) notFound(); + + if (!canAccessReceipt(link)) { + redirect(`/pay/${slug}`); + } + + recordPaymentAudit(link.id, "RECEIPT_VIEW", await headers()); + + return ( +
+
+

{SITE_NAME} Β· Receipt

+ +
+ + + +

+ {SITE_NAME} Β·{" "} + + {COMPANY_WEBSITE} + +

+
+ ); +} diff --git a/src/app/pay/(checkout)/[slug]/return/page.tsx b/src/app/pay/(checkout)/[slug]/return/page.tsx new file mode 100644 index 0000000..774891b --- /dev/null +++ b/src/app/pay/(checkout)/[slug]/return/page.tsx @@ -0,0 +1,51 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { recordPaymentAudit } from "@/lib/db/payment-audit"; +import { handleGatewayReturn } from "@/lib/payments/return"; + +export default async function PayReturnPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise>; +}) { + const { slug } = await params; + const query = await searchParams; + + const handled = await handleGatewayReturn(slug, query); + if (!handled) notFound(); + + const { link, result } = handled; + recordPaymentAudit(link.id, "RETURN_CALLBACK", await headers(), { + outcome: result.outcome, + }); + + if (result.outcome === "receipt") { + redirect(`/pay/${slug}/receipt`); + } + + if (result.outcome === "pay") { + redirect(`/pay/${slug}`); + } + + return ( +
+

+ {result.outcome === "cancelled" ? "Payment cancelled" : "Payment status pending"} +

+

+ {result.outcome === "cancelled" + ? "You cancelled the payment. You can try again when ready." + : "We are confirming your payment. This may take a moment."} +

+ + Back to invoice + +
+ ); +} diff --git a/src/app/pay/(checkout)/layout.tsx b/src/app/pay/(checkout)/layout.tsx new file mode 100644 index 0000000..98f3dd2 --- /dev/null +++ b/src/app/pay/(checkout)/layout.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import { Geist, Geist_Mono } from "next/font/google"; +import { createPageMetadata } from "@/lib/metadata"; +import { ClientProviders } from "@/components/ClientProviders"; +import { SITE_NAME } from "@/lib/content"; +import "../../../styles/globals.css"; +import "../../../styles/brand-document.css"; +import "../../../styles/print-document.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata = createPageMetadata({ + title: `Pay | ${SITE_NAME}`, + description: `Secure invoice payment for ${SITE_NAME}. Pay by card or Mobile Money via KPay.`, + path: "/pay", + noIndex: true, +}); + +export default function PayCheckoutLayout({ children }: { children: ReactNode }) { + return ( + + + +
{children}
+
+ + + ); +} diff --git a/src/app/pay/(checkout)/subscribe/[slug]/page.tsx b/src/app/pay/(checkout)/subscribe/[slug]/page.tsx new file mode 100644 index 0000000..4b90f11 --- /dev/null +++ b/src/app/pay/(checkout)/subscribe/[slug]/page.tsx @@ -0,0 +1,78 @@ +import { notFound } from "next/navigation"; +import Mark from "@/components/Mark"; +import { PaymentLegalNotice } from "@/components/payments/PaymentLegalNotice"; +import { SubscribeButton } from "@/components/payments/SubscribeButton"; +import { formatMoney } from "@/lib/currency"; +import { SITE_NAME } from "@/lib/content"; +import { getMaintenanceSubscriptionBySlug } from "@/lib/db/maintenance-subscriptions"; +import { + formatSubscriptionInterval, + subscriptionAmountMajor, +} from "@/lib/subscriptions/types"; + +export default async function SubscribePage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const sub = await getMaintenanceSubscriptionBySlug(slug); + if (!sub) notFound(); + + const amount = formatMoney(subscriptionAmountMajor(sub), sub.currency); + const isActive = sub.status === "ACTIVE"; + + return ( +
+
+ +
+

{SITE_NAME}

+

Maintenance subscription

+
+
+ +
+

+ Software maintenance +

+

{sub.description}

+

+ For {sub.customerName} Β·{" "} + {sub.customerEmail} +

+ +
+
+
Amount
+
{amount}
+
+
+
Billing
+
+ {formatSubscriptionInterval(sub.interval)} +
+
+
+ + {isActive ? ( +
+ This subscription is active. Future charges will be collected automatically + on your card via Paystack. +
+ ) : ( + <> +
+ +
+
+ +
+ + )} +
+ + +
+ ); +} diff --git a/src/app/pay/(checkout)/subscribe/[slug]/return/page.tsx b/src/app/pay/(checkout)/subscribe/[slug]/return/page.tsx new file mode 100644 index 0000000..5b5aa4e --- /dev/null +++ b/src/app/pay/(checkout)/subscribe/[slug]/return/page.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { + applySubscriptionCharge, + resolveSubscriptionFromCharge, +} from "@/lib/subscriptions/actions"; +import { verifyPaystackTransaction } from "@/lib/paystack/client"; +import { getMaintenanceSubscriptionBySlug } from "@/lib/db/maintenance-subscriptions"; + +export default async function SubscribeReturnPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise>; +}) { + const { slug } = await params; + const query = await searchParams; + const sub = await getMaintenanceSubscriptionBySlug(slug); + if (!sub) notFound(); + + if (sub.status === "ACTIVE") { + redirect(`/pay/subscribe/${slug}`); + } + + const reference = String(query.reference ?? query.trxref ?? ""); + if (reference) { + try { + const charge = await verifyPaystackTransaction(reference); + const subscriptionId = await resolveSubscriptionFromCharge(charge); + if (subscriptionId) { + await applySubscriptionCharge(subscriptionId, charge); + redirect(`/pay/subscribe/${slug}`); + } + } catch { + // Show pending UI below. + } + } + + return ( +
+

Confirming subscription

+

+ We are verifying your card with Paystack. This usually takes a few seconds. +

+ + Back to subscription + +
+ ); +} diff --git a/src/app/pay/(legal)/layout.tsx b/src/app/pay/(legal)/layout.tsx new file mode 100644 index 0000000..5e724bf --- /dev/null +++ b/src/app/pay/(legal)/layout.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { Geist, Geist_Mono } from "next/font/google"; +import { createPageMetadata } from "@/lib/metadata"; +import { ClientProviders } from "@/components/ClientProviders"; +import { SITE_NAME } from "@/lib/content"; +import "../../../styles/globals.css"; +import "../../../styles/pay-legal.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata = createPageMetadata({ + title: `Payment processing & data | ${SITE_NAME}`, + description: + "How Stackwise Technologies handles payment processing, customer data, receipts, and checkout.", + path: "/pay/legal", +}); + +export default function PayLegalLayout({ children }: { children: ReactNode }) { + return ( + + + +
{children}
+
+ + + ); +} diff --git a/src/app/pay/(legal)/legal/page.tsx b/src/app/pay/(legal)/legal/page.tsx new file mode 100644 index 0000000..b12859d --- /dev/null +++ b/src/app/pay/(legal)/legal/page.tsx @@ -0,0 +1,127 @@ +import Link from "next/link"; + +export const dynamic = "force-dynamic"; +import Mark from "@/components/Mark"; +import { + COMPANY_RC, + COMPANY_WEBSITE, + COMPANY_WEBSITE_URL, +} from "@/lib/company"; +import { EMAIL, SITE_NAME } from "@/lib/content"; +import { isPaystackConfigured } from "@/lib/paystack/environment"; + +export default function PaymentLegalPage() { + const paystackEnabled = isPaystackConfigured(); + + return ( +
+
+ +
+

{SITE_NAME}

+

Payment processing & data notice

+
+
+ +
+

+ How we handle payments and your data +

+

+ This notice applies when you pay an invoice issued by {SITE_NAME}{" "} + through our secure payment pages ({COMPANY_WEBSITE}/pay/…). +

+
+ +
+

Payment processor

+

+ {paystackEnabled ? ( + <> + Card payments are processed by{" "} + + Paystack + + . Mobile Money payments are processed by{" "} + + KPay + + . Choosing a method redirects you to that provider's hosted + checkout. The provider collects the information required to + complete the transaction under their terms and privacy policy. + + ) : ( + <> + Card and Mobile Money payments are processed by{" "} + + KPay + + . When you choose a payment method you are redirected to KPay's + hosted checkout. KPay collects the information required to complete + the transaction under their terms and privacy policy. + + )} +

+
+ +
+

What we collect

+
    +
  • Name and email address provided on the invoice
  • +
  • Invoice line items, amounts, and currency conversion rates
  • +
  • Payment status, processor reference, and completion timestamp
  • +
  • + IP address and browser user agent on pay pages, stored for internal + auditing (not shared with third parties) +
  • +
+

+ We do not receive or store card numbers, CVV codes, + or mobile-money PINs. +

+
+ +
+

Receipts

+

+ After a successful payment we email a receipt link to the address on + the invoice. Receipt pages are only available once payment is + confirmed. You may print the receipt for your records; it is + formatted for A4 paper. +

+
+ +
+

Data retention

+

+ Payment records are retained for accounting, tax, and dispute + resolution for as long as required by applicable law and our internal + policies. +

+
+ +
+

Contact

+

+ {SITE_NAME} Β· RC: {COMPANY_RC} +
+ Email:{" "} + + {EMAIL} + +
+ Web:{" "} + + {COMPANY_WEBSITE} + +

+
+ +

+ + ← {COMPANY_WEBSITE} + +

+
+ ); +} diff --git a/src/components/ClientProviders.tsx b/src/components/ClientProviders.tsx new file mode 100644 index 0000000..4fc1f30 --- /dev/null +++ b/src/components/ClientProviders.tsx @@ -0,0 +1,8 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ToastProvider } from "@/components/ToastProvider"; + +export function ClientProviders({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/components/CopyCommand.tsx b/src/components/CopyCommand.tsx index 2e39d4f..368b34f 100644 --- a/src/components/CopyCommand.tsx +++ b/src/components/CopyCommand.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import { useToast } from "@/components/ToastProvider"; +import { BTN_PRESS } from "@/lib/ui/buttons"; export default function CopyCommand({ value, @@ -13,6 +15,7 @@ export default function CopyCommand({ copiedLabel: string; className?: string; }) { + const toast = useToast(); const [copied, setCopied] = useState(false); async function copy() { @@ -22,6 +25,7 @@ export default function CopyCommand({ window.setTimeout(() => setCopied(false), 1600); } catch { setCopied(false); + toast.error("Could not copy to clipboard."); } } @@ -29,7 +33,7 @@ export default function CopyCommand({ {isOpen ? ( -
+

{item.a}

{item.points ? (
    diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 286cbe8..a37c7af 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -14,7 +14,7 @@ export default function Footer({ copy }: { copy: SiteCopy["footer"] }) {

{hero.headline} + + {hero.headlineEmphasis} +

{hero.subtitle}

- {hero.primaryCta} + {hero.primaryCta} {hero.secondaryCta}
-

- ✦ +

{hero.offer}

{hero.requirement}

@@ -105,6 +108,117 @@ function Hero({ locale, copy }: { locale: Locale; copy: SiteCopy }) { ); } +function Trust({ copy }: { copy: SiteCopy["trust"] }) { + return ( +
+ {copy.label} +

+ {copy.title} +

+
+ {copy.paragraphs.map((paragraph) => ( +

{paragraph}

+ ))} +
+ +
+
+

+ {copy.beforeLabel} +

+
    + {copy.beforeItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+

+ {copy.afterLabel} +

+
    + {copy.afterItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+

+ {copy.closer} +

+
+ ); +} + +function Process({ copy }: { copy: SiteCopy["process"] }) { + const [first, second] = copy.steps; + return ( +
+
+ {copy.label} +

+ {copy.title} +

+

{copy.subtitle}

+ +
    + {first ? ( +
  1. +

    + {first.title} +

    +

    + {first.body} +

    +

    {first.detail}

    +
  2. + ) : null} + {second ? ( +
  3. +

    + {second.title} +

    +

    + {second.body} +

    +

    {second.detail}

    +
  4. + ) : null} +
+

+ {copy.timelineNote} +

+
+
+ ); +} + function Features({ copy }: { copy: SiteCopy["features"] }) { return (
@@ -276,7 +390,10 @@ function CompareTable({ function Architecture({ copy }: { copy: SiteCopy["architecture"] }) { return ( -
+
{copy.label}

{copy.title} @@ -294,9 +411,8 @@ function Architecture({ copy }: { copy: SiteCopy["architecture"] }) {

{copy.steps.map((step) => ( -
-

{step.n}

-

+
+

{step.title}

@@ -340,15 +456,12 @@ function Cta({ copy }: { copy: SiteCopy }) {

- {hero.primaryCta} + {hero.primaryCta} {hero.secondaryCta}
-

- ✦ - {cta.offer} -

+

{cta.offer}

{cta.requirement}

@@ -363,7 +476,7 @@ function Cta({ copy }: { copy: SiteCopy }) {

{cta.enterpriseBefore}{" "} + + + diff --git a/src/components/Portfolio.tsx b/src/components/Portfolio.tsx new file mode 100644 index 0000000..5875086 --- /dev/null +++ b/src/components/Portfolio.tsx @@ -0,0 +1,57 @@ +import Image from "next/image"; +import type { SiteCopy } from "@/lib/content"; + +export default function Portfolio({ copy }: { copy: SiteCopy["portfolio"] }) { + return ( +

+

{copy.label}

+

+ {copy.title} +

+

{copy.subtitle}

+ +
+
+ ); +} diff --git a/src/components/SiteNav.tsx b/src/components/SiteNav.tsx index 0110e47..ddf7f9c 100644 --- a/src/components/SiteNav.tsx +++ b/src/components/SiteNav.tsx @@ -2,7 +2,7 @@ import LanguageSwitcher from "@/components/LanguageSwitcher"; import Mark from "@/components/Mark"; -import { CALENDLY_URL, type Locale, type SiteCopy } from "@/lib/content"; +import { bookingUrl, type Locale, type SiteCopy } from "@/lib/content"; import { useState } from "react"; export default function SiteNav({ @@ -51,7 +51,7 @@ export default function SiteNav({ {copy.email} */} + ); +} diff --git a/src/components/admin/AdminCheckoutStatus.tsx b/src/components/admin/AdminCheckoutStatus.tsx new file mode 100644 index 0000000..efbc6da --- /dev/null +++ b/src/components/admin/AdminCheckoutStatus.tsx @@ -0,0 +1,23 @@ +import { EnvironmentBadge } from "@/components/admin/EnvironmentBadge"; +import { adminCheckoutStatus } from "@/lib/payments/admin-checkout"; + +export function AdminCheckoutStatus() { + const status = adminCheckoutStatus(); + + return ( +
+
+ Card checkout: + + {status.cardProcessor === "PAYSTACK" ? "Paystack" : "KPay"} + + +
+
+ Mobile Money: + KPay + +
+
+ ); +} diff --git a/src/components/admin/AdminNavIcon.tsx b/src/components/admin/AdminNavIcon.tsx new file mode 100644 index 0000000..5f7c3de --- /dev/null +++ b/src/components/admin/AdminNavIcon.tsx @@ -0,0 +1,93 @@ +import type { AdminNavItem } from "@/lib/admin/nav-meta"; + +export type AdminNavIconName = AdminNavItem["icon"] | "logout"; + +export function AdminNavIcon({ + name, + className = "size-4", +}: { + name: AdminNavIconName; + className?: string; +}) { + const props = { + className, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.75, + "aria-hidden": true as const, + }; + + switch (name) { + case "links": + return ( + + + + + ); + case "contracts": + return ( + + + + + + + ); + case "subscription": + return ( + + + + + ); + case "withdraw": + return ( + + + + + + ); + case "logs": + return ( + + + + + + + + + ); + case "growth": + return ( + + + + + + + ); + case "settings": + return ( + + + + + ); + case "logout": + return ( + + + + + + ); + default: + return null; + } +} diff --git a/src/components/admin/AdminPageHeader.tsx b/src/components/admin/AdminPageHeader.tsx new file mode 100644 index 0000000..094b114 --- /dev/null +++ b/src/components/admin/AdminPageHeader.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; + +export function AdminPageHeader({ + title, + description, + actions, + meta, +}: { + title: string; + description?: ReactNode; + actions?: ReactNode; + meta?: ReactNode; +}) { + return ( +
+
+
+

+ {title} +

+ {description ? ( +
+ {description} +
+ ) : null} + {meta} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+
+ ); +} diff --git a/src/components/admin/AdminShell.tsx b/src/components/admin/AdminShell.tsx new file mode 100644 index 0000000..989e98a --- /dev/null +++ b/src/components/admin/AdminShell.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { AdminNavIcon } from "@/components/admin/AdminNavIcon"; +import { AdminUserAvatar } from "@/components/admin/AdminUserAvatar"; +import Mark from "@/components/Mark"; +import { useToast } from "@/components/ToastProvider"; +import { + adminGrowthNavChildren, + adminNavPrimary, + adminPageTitle, + isNavActive, + isNavChildActive, +} from "@/lib/admin/nav-meta"; +import { SITE_NAME } from "@/lib/content"; +import { ADMIN_NAV_LINK } from "@/lib/ui/admin"; +import Link from "next/link"; +import { + getAdminSidebarCollapsed, + getAdminSidebarCollapsedServerSnapshot, + setAdminSidebarCollapsed, + subscribeAdminSidebar, +} from "@/lib/admin/sidebar-preference"; +import { useDeferredEffect } from "@/lib/use-deferred-effect"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useSyncExternalStore, useState, type ReactNode } from "react"; + +function SidebarBrand({ collapsed }: { collapsed: boolean }) { + return ( +
+ + {!collapsed ? ( +
+

Stackwise

+

Payments

+
+ ) : null} +
+ ); +} + +function CollapseIcon({ collapsed }: { collapsed: boolean }) { + return ( + + + + ); +} + +function SidebarNav({ + pathname, + collapsed, + onNavigate, +}: { + pathname: string; + collapsed: boolean; + onNavigate?: () => void; +}) { + return ( + + ); +} + +function SidebarFooter({ + collapsed, + onToggleCollapse, +}: { + collapsed: boolean; + onToggleCollapse: () => void; +}) { + return ( +
+ +
+ ); +} + +function DesktopSidebar({ + pathname, + collapsed, + onToggleCollapse, +}: { + pathname: string; + collapsed: boolean; + onToggleCollapse: () => void; +}) { + return ( + + ); +} + +export type AdminShellUser = { + email: string; + avatarUrl: string; +}; + +export function AdminShell({ + children, + user, +}: { + children: ReactNode; + user: AdminShellUser | null; +}) { + const pathname = usePathname(); + const router = useRouter(); + const toast = useToast(); + const isLogin = pathname === "/admin/login"; + const [menuOpen, setMenuOpen] = useState(false); + const sidebarCollapsed = useSyncExternalStore( + subscribeAdminSidebar, + getAdminSidebarCollapsed, + getAdminSidebarCollapsedServerSnapshot, + ); + const pageTitle = adminPageTitle(pathname); + + useDeferredEffect(() => { + setMenuOpen(false); + }, [pathname]); + + useEffect(() => { + if (!menuOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setMenuOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [menuOpen]); + + function toggleSidebar() { + setAdminSidebarCollapsed(!sidebarCollapsed); + } + + async function logout() { + try { + const res = await fetch("/api/admin/logout", { method: "POST" }); + if (!res.ok) throw new Error("Sign out failed"); + router.push("/admin/login"); + router.refresh(); + } catch { + toast.error("Could not sign out. Please try again."); + } + } + + if (isLogin) { + return ( +
{children}
+ ); + } + + return ( +
+ + +
+
+
+ + +
+
+ {user ? ( + void logout()} + /> + ) : null} +
+
+ + {menuOpen ? ( + <> +
+
+ ); +} diff --git a/src/components/admin/AdminUserAvatar.tsx b/src/components/admin/AdminUserAvatar.tsx new file mode 100644 index 0000000..f2ac63e --- /dev/null +++ b/src/components/admin/AdminUserAvatar.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { AdminNavIcon } from "@/components/admin/AdminNavIcon"; +import Image from "next/image"; +import { useEffect, useId, useRef, useState } from "react"; + +export function AdminUserAvatar({ + email, + avatarUrl, + onSignOut, +}: { + email: string; + avatarUrl: string; + onSignOut: () => void; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const menuId = useId(); + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + window.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onPointerDown); + window.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
+ + + {open ? ( + + ) : null} +
+ ); +} diff --git a/src/components/admin/AuditLogDetailDialog.tsx b/src/components/admin/AuditLogDetailDialog.tsx new file mode 100644 index 0000000..aa2a13a --- /dev/null +++ b/src/components/admin/AuditLogDetailDialog.tsx @@ -0,0 +1,236 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, type ReactNode } from "react"; +import type { PaymentAuditLog, PaymentAuditLogWithLink } from "@/lib/db/payment-audit"; +import { ipGeoDetailRows } from "@/lib/ip-geo"; +import { + formatPaymentInitAuditDetail, + PAYMENT_AUDIT_EVENT_LABELS, +} from "@/lib/payments/audit-labels"; + +type AuditRow = PaymentAuditLog | PaymentAuditLogWithLink; + +function hasLinkContext(log: AuditRow): log is PaymentAuditLogWithLink { + return "invoiceNumber" in log && "customerName" in log; +} + +function formatDetailTimestamp(timestamp: number): string { + return new Date(timestamp) + .toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + hour12: false, + timeZoneName: "short", + }) + .toUpperCase(); +} + +function metadataTags(log: AuditRow): { key: string; value: string }[] { + const tags: { key: string; value: string }[] = [ + { key: "event", value: log.event }, + ]; + if (log.ipAddress) tags.push({ key: "ip", value: log.ipAddress }); + if (log.metadata && typeof log.metadata === "object") { + for (const [key, value] of Object.entries(log.metadata)) { + if (value === null || value === undefined) continue; + tags.push({ + key, + value: typeof value === "string" ? value : JSON.stringify(value), + }); + } + } + const initDetail = formatPaymentInitAuditDetail(log.metadata); + if (initDetail) tags.push({ key: "processor", value: initDetail }); + return tags; +} + +function DetailRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +export function AuditLogDetailDialog({ + log, + onClose, +}: { + log: AuditRow | null; + onClose: () => void; +}) { + const copyPayload = useCallback(() => { + if (!log) return; + void navigator.clipboard.writeText(JSON.stringify(log, null, 2)); + }, [log]); + + useEffect(() => { + if (!log) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [log, onClose]); + + if (!log) return null; + + const eventLabel = PAYMENT_AUDIT_EVENT_LABELS[log.event] ?? log.event; + const tags = metadataTags(log); + const geoRows = ipGeoDetailRows(log.ipGeo); + + return ( +
+ + +
+ + +
+
+ Event started + +
+ +
+
+ + {log.id} + + + + {log.paymentLinkId} + + + {hasLinkContext(log) && ( + + + {log.invoiceNumber} + + + )} + + {log.ipAddress ?? "β€”"} + + {geoRows.length > 0 ? ( +
+

+ IP geolocation & network +

+
+ {geoRows.map((row) => ( + + {row.value} + + ))} +
+
+ ) : null} + +

+ {log.userAgent ?? "β€”"} +

+
+
+ +
+

Metadata

+
+ {tags.map((tag) => ( + + {tag.key}: + {tag.value} + + ))} +
+
+
+ +

+ + Event recorded +

+
+
+
+ ); +} diff --git a/src/components/admin/ContractBuilder.tsx b/src/components/admin/ContractBuilder.tsx new file mode 100644 index 0000000..9c331d3 --- /dev/null +++ b/src/components/admin/ContractBuilder.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useToast } from "@/components/ToastProvider"; +import { + CONTRACT_DOCUMENT_KINDS, + contractDocumentKindLabel, + type ContractDocumentKind, +} from "@/lib/contracts/types"; +import { BTN_PRIMARY, BTN_SECONDARY } from "@/lib/ui/buttons"; + +type Props = { + initialCustomerName?: string; + initialCustomerEmail?: string; + initialCompanyName?: string; + initialBrief?: string; + leadId?: string; +}; + +export function ContractBuilder({ + initialCustomerName = "", + initialCustomerEmail = "", + initialCompanyName = "", + initialBrief = "", + leadId, +}: Props) { + const router = useRouter(); + const toast = useToast(); + const [customerName, setCustomerName] = useState(initialCustomerName); + const [customerEmail, setCustomerEmail] = useState(initialCustomerEmail); + const [companyName, setCompanyName] = useState(initialCompanyName); + const [documentKind, setDocumentKind] = useState("sow"); + const [servicesBrief, setServicesBrief] = useState(initialBrief); + const [commercialNotes, setCommercialNotes] = useState(""); + const [loading, setLoading] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + try { + const res = await fetch("/api/admin/contracts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + customerName, + customerEmail, + companyName: companyName || undefined, + documentKind, + servicesBrief, + commercialNotes: commercialNotes || undefined, + leadId, + }), + }); + const data = (await res.json()) as { error?: string; engagement?: { id: string } }; + if (!res.ok) throw new Error(data.error ?? "Failed to create agreement"); + toast.success("Draft generated β€” review before sending to the client."); + router.push(`/admin/contracts/${data.engagement!.id}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to create"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + +
+ + + + + +