diff --git a/.env.example b/.env.example index b8cfc8fa..a97aba7f 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,7 @@ NEXT_PUBLIC_RB2B_KEY= # Search mode: 'fumadocs' (default, uses Fumadocs built-in search) or 'rag' (uses RAG endpoint at mcp.superwall.com) SEARCH_MODE=fumadocs + +NEXT_PUBLIC_PYLON_APP_ID= +PYLON_IDENTITY_SECRET= +OPENAI_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1d4cfd15..79c756eb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ wrangler.jsonc wrangler.ci.jsonc wrangler.local.jsonc src/lib/title-map.json +tsconfig.tsbuildinfo diff --git a/AI_CHAT_V2_PLAN.md b/AI_CHAT_V2_PLAN.md new file mode 100644 index 00000000..6358fa0f --- /dev/null +++ b/AI_CHAT_V2_PLAN.md @@ -0,0 +1,220 @@ +# AI Chat v2 — Unified Plan (Cloudflare Workers, AI SDK v5, GPT-5, local-only, single sidebar thread) + +## Objectives +- Cursor-style sidebar chat that persists across navigation. +- Streams replies from **OpenAI GPT-5** (via `@ai-sdk/openai`) behind your Worker route. +- Uses **current page context** by default, can pull **full page text** on demand. +- Tools: **page.context**, **mcp.search**, **docs.search**. +- **Local-only storage**: one chat thread in the browser; no server DB. + +--- + +## Architecture (recommended: merged repo) +Single codebase for docs + API. Simpler types, fewer moving parts, faster POC. + +app/ + api/ai/route.(ts|js) ← AI route (Workers/Pages Function) using AI SDK Core streamText + tools + (docs)/ ← Fumadocs content (.md/.mdx) + components/ChatSidebar.tsx ← Sidebar UI (AI SDK UI useChat) + ai/tools.(ts|js) ← Zod-typed tools: page.context, mcp.search, docs.search + lib/page-context.(ts|js) ← Helpers to collect light context (url, docId, title, headings) + lib/local-store.(ts|js) ← LocalStorage helpers (load/save/prune UIMessage[]) + +Secrets live in Cloudflare env; safe for open source. + +### Alternative: split repos (frontend + backend) +- Frontend uses `useChat({ api: 'https:///api/ai' })`. +- Backend exposes the same streamText + tools. +- Trade-off: more CORS/version friction, you lose co-located types. + +--- + +## Providers & Models +- **OpenAI** via `@ai-sdk/openai`, `openai('gpt-5')`. +- Optional later: Cloudflare **AI Gateway** in front of OpenAI for routing/analytics. +- Optional later: Workers AI via `workers-ai-provider` if you want on-Cloudflare models. + +--- + +## Message Shape (v5 best practice) +Define one typed **UIMessage** for client+server. Include doc metadata. + +type DocContext = { url: string; docId: string; title?: string; headings?: string[] } +type AppMessage = UIMessage<{ doc?: DocContext }> + +Rationale: full-stack type safety; clean tool traces; consistent persistence. + +--- + +## Tools +1) page.context + - input: { docId | url } + - return: bounded text slices of the current doc (chunked, token-budgeted) + - policy: never dump entire page by default; fetch on demand + +2) mcp.search + - input: { query, docId?, url? } + - call your MCP HTTP/SSE endpoint + - return: [{ title, url, snippet, score }...] + +3) docs.search (non-MCP) + - input: { query, docId?, url? } + - hits your existing search API + - return: same shape as mcp.search for uniform rendering + +Implementation notes: +- Zod-validate inputs; strict timeouts; cap payload sizes. +- If long-running, return partials and tell the model to “continue”. + +--- + +## Context Strategy +- Every user send includes **light context**: { url, docId, title, 3–6 headings }. +- The model calls **page.context** if it needs full text slices. +- For long docs, chunk by headings/paragraphs and rank top-K segments. + +--- + +## Sidebar UX +- Fixed right dock; toggle via FAB. +- Persists across navigation (mount in root layout). +- One chat only: id = "sidebar". +- Show small “Sources” list for tool URLs; “Ask this page” button injects selection. + +--- + +## Persistence (local-only) +- Store **UIMessage[]** in **localStorage** keyed by "chat:sidebar". +- On mount: hydrate from localStorage; on finish/stream end: write back. +- Cap total size (~150–250 KB). If exceeded: + - Ask model for a short “conversation summary” system note, keep it, drop oldest turns. + +Caveats: +- No stream resume after tab crash (acceptable for POC). +- Clear chat button wipes localStorage key. + +--- + +## Security & Ops +- Only server calls hold secrets (OPENAI_API_KEY in Workers env). +- Rate limit `/api/ai` by IP/session. +- Validate messages/tools; reject oversized inputs; sanitize HTML. +- Log tool inputs/outputs (without PII) for debugging. + +--- + +## Model & Generation Settings (initial) +- model: gpt-5 +- max output tokens: short (sidebar answers) +- tool parallelism: off initially +- system instructions: brief; prefer page.context first, then mcp.search/docs.search; cite URLs returned by tools + +--- + +## File Map (merged) +app/ + api/ai/route.ts [NEW] streamText + tools, returns UIMessage stream + (docs)/... [EXISTING] .md/.mdx content + components/ + ChatSidebar.tsx [NEW] useChat + local storage glue + ChatFAB.tsx [NEW] + ChatMessage.tsx [NEW] + ai/ + tools.ts [NEW] page.context, mcp.search, docs.search (Zod) + message-types.ts [NEW] AppMessage + DocContext + lib/ + page-context.ts [NEW] extract url/docId/title/headings + local-store.ts [NEW] load/save/prune UIMessage[] + +### Split variant (if you refuse to merge) +frontend/ + app/api/ai/route.ts [NEW] proxy to backend (CORS/auth) +backend/ + src/chat-handler.ts [NEW] streamText + tools + src/tools.ts [NEW] + src/index.ts [MOD] add /api/ai route + +--- + +## Rollout +1) Backend route working with curl (streams). +2) Minimal sidebar wired to route (streams visible). +3) Add light page context on each send. +4) Implement page.context tool (bounded slices). +5) Wire mcp.search + docs.search tools. +6) Add local storage persistence + pruning. +7) Ship to staging; verify rate limits and errors. +8) Ship to prod. + +--- + +## Testing +- Streaming under flaky networks. +- Context injection correctness (slug/title/headings). +- Tool calls: latency caps + error paths. +- Local storage prune behavior and “clear chat”. +- Mobile layout (sidebar = full-width drawer). + +--- + +## TODOs + +Setup +- [x] Add `ai`, `@ai-sdk/openai`, `zod` to the docs app. +- [ ] Configure `OPENAI_API_KEY` in Cloudflare (no secrets in git). + +Server (Workers/Pages Function) +- [x] Create `src/app/api/ai/route.ts` with `streamText(openai('gpt-5'), { messages, tools })`. +- [ ] Implement Zod validation for incoming `AppMessage[]`. +- [x] Implement tools in `ai/tools.ts`: + - [x] page.context (basic - chunk/cap/rank to be refined) + - [x] mcp.search (HTTP/SSE, compact results) + - [x] docs.search (HTTP, compact results) +- [ ] Add rate limiting + basic input size checks. + +Client +- [x] Build `ChatSidebar.tsx` with `useChat({ id: 'sidebar', api: '/api/ai' })`. +- [x] Implement `local-store.ts` (hydrate/save/prune on finish). +- [x] Extract page metadata in `page-context.ts`; include with each send. +- [x] Add FAB; add "Clear chat" button. +- [ ] Make FAB icon smaller (Intercom-style size). +- [ ] Add Cmd+I keyboard shortcut to toggle chat. +- [ ] Add Esc key to close chat when open. +- [ ] Make Cmd+K search menu and AI chat mutually exclusive (opening one closes the other). +- [ ] Render sources panel when tool URLs present. + +Content & Prompt +- [x] Keep system instructions short; require citing tool URLs. +- [ ] Add "Ask this page" button to prefill selection + title. + +Testing +- [ ] Test streaming and tool calling end-to-end. +- [ ] Verify localStorage persistence works correctly. +- [ ] Test on mobile (sidebar = full-width drawer). + +Polish (nice-to-have after POC) +- [ ] Syntax highlight + copy buttons for code. +- [ ] Tiny transcripts panel with "from: page / search" chips. +- [x] Thumbs up/down to your existing `/api/feedback`. +- [ ] Polish UI with shadcn/AI SDK components where appropriate. +- [ ] Adjustable sidebar width (save for end with other polish/qol). + +Docs +- [ ] README section: local-only storage caveats, how to clear. +- [ ] Note: one chat id ("sidebar") by design; multi-thread is out of scope. + +--- + +## Open questions (please answer) +1) Repo: OK to **merge** now for the POC? If not, confirm you want to stay **split** for the first ship. +- yes +2) GPT-5 snapshot: which exact model id should we pin (e.g., `gpt-5-2025-xx-xx`)? +- no snapshot +3) MCP endpoint: confirm base URL(s) and whether they support **SSE**; any CORS constraints? +- https://mcp.superwall.com/sse +4) Fumadocs runtime: are pages on **Cloudflare Pages Functions** (vs raw Workers) so the route is `/app/api/ai`? +- cloudflare workers +5) Size limits: cap per `page.context` response (e.g., ~3–5k tokens). Acceptable? +- don't cap for now +6) Any CSP headers that would block `fetch`/SSE from the sidebar on your domain? +- i don't think so? \ No newline at end of file diff --git a/bun.lock b/bun.lock index e9e60a49..0c74dbd1 100644 --- a/bun.lock +++ b/bun.lock @@ -4,8 +4,12 @@ "": { "name": "@superwall-me/docs", "dependencies": { + "@ai-sdk/openai": "^2.0.53", + "@ai-sdk/react": "^2.0.76", "@radix-ui/react-dialog": "^1.1.11", + "ai": "^5.0.76", "class-variance-authority": "^0.7.1", + "cli-progress": "^3.12.0", "clsx": "^2.1.0", "fumadocs-core": "15.3.2", "fumadocs-mdx": "<12.0.0", @@ -56,6 +60,16 @@ }, }, "packages": { + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.12", "@vercel/oidc": "3.0.3" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.12" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GIkR3+Fyif516ftXv+YPSPstnAHhcZxNoR2s8uSHhQ1yBT7I7aQYTVwpjAuYoT3GR+TeP50q7onj2/nDRbT2FQ=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.12", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZtbdvYxdMoria+2SlNarEk6Hlgyf+zzcznlD55EAl+7VZvJaSg2sqPvwArY7L6TfDEDJsnCq0fdhBSkYo0Xqdg=="], + + "@ai-sdk/react": ["@ai-sdk/react@2.0.76", "", { "dependencies": { "@ai-sdk/provider-utils": "3.0.12", "ai": "5.0.76", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.25.76 || ^4.1.8" }, "optionalPeers": ["zod"] }, "sha512-ggAPzyaKJTqUWigpxMzI5DuC0Y3iEpDUPCgz6/6CpnKZY/iok+x5xiZhDemeaP0ILw5IQekV0kdgBR8JPgI8zQ=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ast-grep/napi": ["@ast-grep/napi@0.35.0", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.35.0", "@ast-grep/napi-darwin-x64": "0.35.0", "@ast-grep/napi-linux-arm64-gnu": "0.35.0", "@ast-grep/napi-linux-arm64-musl": "0.35.0", "@ast-grep/napi-linux-x64-gnu": "0.35.0", "@ast-grep/napi-linux-x64-musl": "0.35.0", "@ast-grep/napi-win32-arm64-msvc": "0.35.0", "@ast-grep/napi-win32-ia32-msvc": "0.35.0", "@ast-grep/napi-win32-x64-msvc": "0.35.0" } }, "sha512-3ucaaSxV6fxXoqHrE/rxAvP1THnDdY5jNzGlnvx+JvnY9C/dSRKc0jlRMRz59N3El572+/yNRUUpAV1T9aBJug=="], @@ -380,6 +394,8 @@ "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.10.0", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.8.2", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "wrangler": "^4.38.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-hG9o9wjsgbshLkOEuX1EVQ1GhgbFv8VU/nI8atxgCgOHT87+ypsLOsVA4/6ivctpBZ/PLU/E9+b/eYZnqMVd/w=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@orama/orama": ["@orama/orama@3.1.15", "", {}, "sha512-ltjr1WHlY+uqEKE0JG2G6Xn36mSQGmPdPGQedQyipekBdf0iAtp8oL9dckQRX8cP+nUfOZwwPWSu7km8gGciUg=="], "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], @@ -650,6 +666,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vercel/oidc": ["@vercel/oidc@3.0.3", "", {}, "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -662,6 +680,8 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + "ai": ["ai@5.0.76", "", { "dependencies": { "@ai-sdk/gateway": "2.0.0", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.12", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZCxi1vrpyCUnDbtYrO/W8GLvyacV9689f00yshTIQ3mFFphbD7eIv40a2AOZBv3GGRA7SSRYIDnr56wcS/gyQg=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -738,6 +758,8 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], @@ -822,7 +844,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.234", "", {}, "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg=="], - "emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -880,6 +902,8 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], @@ -1110,6 +1134,8 @@ "json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -1544,7 +1570,7 @@ "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], - "string-width": ["string-width@6.1.0", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^10.2.1", "strip-ansi": "^7.0.1" } }, "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1580,6 +1606,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="], + "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], @@ -1590,6 +1618,8 @@ "terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="], + "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], + "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -1658,6 +1688,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], @@ -2392,14 +2424,12 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "tsx/esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], + "vfile-reporter/string-width": ["string-width@6.1.0", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^10.2.1", "strip-ansi": "^7.0.1" } }, "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ=="], + "wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -2410,8 +2440,6 @@ "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.6.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA=="], @@ -2902,6 +2930,8 @@ "body-parser/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "cliui/string-width/emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "cloudflare/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -3022,8 +3052,6 @@ "router/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="], @@ -3074,6 +3102,10 @@ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="], + "vfile-reporter/string-width/emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], + + "vfile-reporter/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], @@ -3126,10 +3158,12 @@ "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "yargs/string-width/emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], + "yargs/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "@aws-sdk/client-dynamodb/@aws-crypto/sha256-js/@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -3280,6 +3314,8 @@ "foreground-child/cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "vfile-reporter/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/content/docs/dashboard/integrations.mdx b/content/docs/dashboard/integrations.mdx deleted file mode 100644 index 5cc94cdd..00000000 --- a/content/docs/dashboard/integrations.mdx +++ /dev/null @@ -1,728 +0,0 @@ ---- -title: "Integrations" -description: "Use webhooks to get real-time notifications about your app's subscription and payment events. Integrate Superwall with other services." ---- - -The integrations page is where you can manage your webhooks and other integrations with Superwall: - -![](/images/overview-integrations.jpeg) - -## Webhooks - -Superwall sends webhooks to notify your application about important subscription and payment events in real-time. These webhooks are designed to closely match App Store and other revenue provider events, minimizing migration difficulty. - -**Important Design Principle**: Webhook events are structured so that summing `proceeds` or `price` across all events (without filtering) accurately represents total revenue net of refunds. To calculate gross revenue, filter out events with negative proceeds. - -### Webhook Payload Structure - -Every webhook sent by Superwall contains the following structure: - -```json -{ - "object": "event", - "type": "renewal", - "projectId": 3827, - "applicationId": 1, - "timestamp": 1754067715103, - "data": { - "id": "42fc6339-dc28-470b-a0fa-0d13c92d8b61:renewal", - "name": "renewal", - "cancelReason": null, - "exchangeRate": 1.0, - "isSmallBusiness": false, - "periodType": "NORMAL", - "countryCode": "US", - "price": 9.99, - "proceeds": 6.99, - "priceInPurchasedCurrency": 9.99, - "taxPercentage": 0, - "commissionPercentage": 0.3, - "takehomePercentage": 0.7, - "offerCode": null, - "isFamilyShare": false, - "expirationAt": 1756659704000, - "transactionId": "700002054157982", - "originalTransactionId": "700002050981465", - "originalAppUserId": "$SuperwallAlias:7152E89E-60A6-4B2E-9C67-D7ED8F5BE372", - "store": "APP_STORE", - "purchasedAt": 1754067704000, - "currencyCode": "USD", - "productId": "com.example.premium.monthly", - "environment": "PRODUCTION", - "isTrialConversion": false, - "newProductId": null, - "bundleId": "com.example.app", - "ts": 1754067710106 - } -} -``` - -### Webhook Payload Fields - -| Field | Type | Description | -| --------------- | ------ | -------------------------------------------------------------------- | -| `object` | string | Always "event" | -| `type` | string | The event type (e.g., "initial_purchase", "renewal", "cancellation") | -| `projectId` | number | Your Superwall project ID | -| `applicationId` | number | Your Superwall application ID | -| `timestamp` | number | Event timestamp in milliseconds since epoch | -| `data` | object | Event-specific data (see below) | - -## Event Data Object - -The `data` field contains detailed information about the subscription or payment event: - -### Event Data Fields - -| Field | Type | Description | -| -------------------------- | ----------------- | ------------------------------------------------------------------------- | -| `id` | string | Unique identifier for this event | -| `name` | string | Event name (see [Event Names](#event-names)) | -| `cancelReason` | string or null | Reason for cancellation (see [Cancel Reasons](#cancelexpiration-reasons)) | -| `exchangeRate` | number | Exchange rate used to convert to USD | -| `isSmallBusiness` | boolean | Small business program participant | -| `periodType` | string | Period type: `TRIAL`, `INTRO`, or `NORMAL` | -| `countryCode` | string | ISO country code (e.g., "US") | -| `price` | number | Transaction price in USD (negative for refunds) | -| `proceeds` | number | Net proceeds in USD after taxes and fees | -| `priceInPurchasedCurrency` | number | Price in original currency | -| `taxPercentage` | number or null | Tax percentage applied | -| `commissionPercentage` | number | Store commission percentage | -| `takehomePercentage` | number | Your percentage after commission | -| `offerCode` | string or null | Promotional offer code used | -| `isFamilyShare` | boolean | Family sharing purchase | -| `expirationAt` | number or null | Expiration timestamp (milliseconds) | -| `transactionId` | string | Current transaction ID | -| `originalTransactionId` | string | Original transaction ID (subscription ID) | -| `originalAppUserId` | string or null | Original app user ID — requires SDK v4.5.2+ (see [details](#understanding-originalappuserid)) | -| `store` | string | Store: `APP_STORE`, `PLAY_STORE`, `STRIPE`, or `PADDLE` (see note below) | -| `purchasedAt` | number | Purchase timestamp (milliseconds) | -| `currencyCode` | string | ISO currency code for priceInPurchasedCurrency | -| `productId` | string | Product identifier | -| `environment` | string | `PRODUCTION` or `SANDBOX` | -| `isTrialConversion` | boolean | Trial to paid conversion | -| `newProductId` | string or null | New product ID (for product changes) | -| `bundleId` | string | App bundle identifier | -| `ts` | number | Event timestamp (milliseconds) | -| `expirationReason` | string (optional) | Reason for expiration (see [Cancel Reasons](#cancelexpiration-reasons)) | -| `checkoutContext` | object (optional) | Stripe-specific checkout context | - -**Note on Store field:** iOS and Android apps can receive events from any payment provider. For example, an iOS app can receive `STRIPE` or `PADDLE` events when users purchase through Superwall's App2Web features, which allow web-based checkout flows within mobile apps. The `store` field indicates where the payment was processed, not which platform the app runs on. - -## Event Names - -| Event Name | Value | Description | -| --------------------- | ----------------------- | ----------------------------------- | -| Initial Purchase | `initial_purchase` | First-time subscription or purchase | -| Renewal | `renewal` | Subscription renewal | -| Cancellation | `cancellation` | Subscription cancelled | -| Uncancellation | `uncancellation` | Subscription reactivated | -| Expiration | `expiration` | Subscription expired | -| Billing Issue | `billing_issue` | Payment processing failed | -| Product Change | `product_change` | User changed subscription tier | -| Subscription Paused | `subscription_paused` | Subscription temporarily paused | -| Non-Renewing Purchase | `non_renewing_purchase` | One-time purchase | -| Test | `test` | Test event for webhook verification | - -## Period Types - -| Period Type | Value | Description | -| ----------- | -------- | ------------------------------------------- | -| Trial | `TRIAL` | Free trial period | -| Intro | `INTRO` | Introductory offer period (discounted rate) | -| Normal | `NORMAL` | Regular subscription period (full price) | - -## Stores - -| Store | Value | Description | -| ---------- | ------------ | ----------------------------- | -| App Store | `APP_STORE` | Apple App Store | -| Play Store | `PLAY_STORE` | Google Play Store | -| Stripe | `STRIPE` | Stripe payments | -| Paddle | `PADDLE` | Paddle payments (coming soon) | - -## Environments - -| Environment | Value | Description | -| ----------- | ------------ | ---------------------------------- | -| Production | `PRODUCTION` | Live production transactions | -| Sandbox | `SANDBOX` | Test transactions (not real money) | - -## Cancel/Expiration Reasons - -| Reason | Value | Description | -| ------------------- | --------------------- | ----------------------------- | -| Billing Error | `BILLING_ERROR` | Payment method failed | -| Customer Support | `CUSTOMER_SUPPORT` | Cancelled via support | -| Unsubscribe | `UNSUBSCRIBE` | User-initiated cancellation | -| Price Increase | `PRICE_INCREASE` | Cancelled due to price change | -| Developer Initiated | `DEVELOPER_INITIATED` | Cancelled programmatically | -| Unknown | `UNKNOWN` | Reason not specified | - -## Common Use Cases - -### Detecting Trial Starts - -```javascript -if ( - event.data.periodType === "TRIAL" && - event.data.name === "initial_purchase" -) { - // New trial started -} -``` - -### Detecting Trial Conversions - -```javascript -if ( - event.data.name === "renewal" && - (event.data.isTrialConversion || - event.data.periodType === "TRIAL" || - event.data.periodType === "INTRO") -) { - // Trial or intro offer converted to paid subscription -} -``` - -### Detecting Trial Cancellations - -```javascript -if (event.data.periodType === "TRIAL" && event.data.name === "cancellation") { - // Trial cancelled -} -``` - -### Detecting Trial Uncancellations (Reactivations) - -```javascript -if (event.data.periodType === "TRIAL" && event.data.name === "uncancellation") { - // Trial reactivated after cancellation -} -``` - -### Detecting Trial Expirations - -```javascript -if (event.data.periodType === "TRIAL" && event.data.name === "expiration") { - // Trial expired -} -``` - -### Detecting Intro Offer Starts - -```javascript -if ( - event.data.periodType === "INTRO" && - event.data.name === "initial_purchase" -) { - // Intro offer started -} -``` - -### Detecting Intro Offer Cancellations - -```javascript -if (event.data.periodType === "INTRO" && event.data.name === "cancellation") { - // Intro offer cancelled -} -``` - -### Detecting Intro Offer Uncancellations - -```javascript -if (event.data.periodType === "INTRO" && event.data.name === "uncancellation") { - // Intro offer reactivated -} -``` - -### Detecting Intro Offer Expirations - -```javascript -if (event.data.periodType === "INTRO" && event.data.name === "expiration") { - // Intro offer expired -} -``` - -### Detecting Intro Offer Conversions - -```javascript -if (event.data.periodType === "INTRO" && event.data.name === "renewal") { - // Intro offer converted to regular subscription -} -``` - -### Detecting Subscription Starts - -```javascript -if ( - event.data.periodType === "NORMAL" && - event.data.name === "initial_purchase" -) { - // New paid subscription started -} -``` - -### Detecting Renewals - -```javascript -if ( - event.data.name === "renewal" && - event.data.periodType === "NORMAL" && - !event.data.isTrialConversion -) { - // Regular subscription renewal -} -``` - -### Detecting Refunds - -```javascript -if (event.data.price < 0) { - // Refund processed - const refundAmount = Math.abs(event.data.price); -} -``` - -### Detecting Cancellations - -```javascript -if (event.data.name === "cancellation") { - // Subscription cancelled - // Check cancelReason for details - const reason = event.data.cancelReason; -} -``` - -### Detecting Subscription Expirations - -```javascript -if (event.data.name === "expiration") { - // Subscription expired - // Check expirationReason for details -} -``` - -### Detecting Billing Issues - -```javascript -if (event.data.name === "billing_issue") { - // Payment failed - subscription at risk -} -``` - -### Detecting Subscription Pauses - -```javascript -if (event.data.name === "subscription_paused") { - // Subscription has been paused -} -``` - -### Detecting Product Changes - -```javascript -if (event.data.name === "product_change") { - // User changed subscription plan - const oldProduct = event.data.productId; - const newProduct = event.data.newProductId; -} -``` - -### Detecting Subscription Reactivations - -```javascript -if (event.data.name === "uncancellation") { - // Previously cancelled subscription was reactivated -} -``` - -### Detecting Non-Renewing Purchases - -```javascript -if (event.data.name === "non_renewing_purchase") { - // One-time purchase completed -} -``` - -### Detecting Revenue Events - -```javascript -if (event.data.price !== 0 || event.data.name === "non_renewing_purchase") { - // This event involves revenue (positive or negative) -} -``` - -### Detecting Test Events - -```javascript -if (event.data.name === "test") { - // Test webhook for endpoint verification -} -``` - -## Revenue Calculation - -### Total Net Revenue (Including Refunds) - -```javascript -// Sum all proceeds - automatically accounts for refunds -const netRevenue = events.reduce((sum, event) => sum + event.data.proceeds, 0); -``` - -### Gross Revenue (Excluding Refunds) - -```javascript -// Only sum positive proceeds -const grossRevenue = events.reduce( - (sum, event) => (event.data.proceeds > 0 ? sum + event.data.proceeds : sum), - 0 -); -``` - -### Refund Total - -```javascript -// Sum negative proceeds -const refunds = events.reduce( - (sum, event) => - event.data.proceeds < 0 ? sum + Math.abs(event.data.proceeds) : sum, - 0 -); -``` - -### Revenue by Product - -```javascript -const revenueByProduct = {}; -events.forEach((event) => { - const productId = event.data.productId; - if (!revenueByProduct[productId]) { - revenueByProduct[productId] = 0; - } - revenueByProduct[productId] += event.data.proceeds; -}); -``` - -## Testing Webhooks - -Test events can be identified by: - -- `event.data.name === "test"` -- These events have minimal data and are used for webhook endpoint verification - -## Best Practices - -1. **Handle duplicate events** - Use `event.id` for idempotency -2. **Process webhooks asynchronously** - Return 200 immediately, then process -3. **Store raw webhook data** for debugging and reconciliation -4. **Handle all event types** - Even if you don't process them immediately -5. **Monitor webhook failures** - Implement retry logic for critical events -6. **Use timestamps** - All timestamps are in milliseconds since epoch - -## Store-Specific Behaviors - -### Commission Rates by Store - -**APP_STORE:** - -- Standard rate: 30% -- Small Business Program rate: 15% (for eligible developers) -- Clean, predictable commission structure - -**PLAY_STORE:** - -- Variable rates from 11.8% to 15% -- Most common rate: 15% -- Rates can vary based on region and other factors - -**STRIPE:** - -- Variable rates from 0% to ~7.2% -- Generally lower than mobile app stores -- Depends on Stripe pricing plan and transaction type - -### Price = 0 Events - -Events commonly have `price = 0` for non-revenue scenarios: - -- `billing_issue` - Payment failed, no money collected -- `cancellation` - Subscription cancelled, no charge -- `expiration` - Subscription expired, no charge -- `uncancellation` - Reactivation, no immediate charge -- `product_change` - Plan change notification -- `subscription_paused` - Pause event, no charge - -Revenue events (initial_purchase, renewal, non_renewing_purchase) typically have non-zero prices unless: - -- Family sharing scenario (some cases) -- Special promotional offers -- Test transactions - -### Cancel/Expiration Reasons by Store - -**APP_STORE:** - -- `CUSTOMER_SUPPORT` - Cancelled via Apple support -- `UNSUBSCRIBE` - User-initiated cancellation -- `BILLING_ERROR` - Payment failure - -**PLAY_STORE:** - -- All APP_STORE reasons plus: -- `UNKNOWN` - Reason not specified or unavailable - -**STRIPE:** - -- `UNKNOWN` - Stripe typically doesn't provide detailed cancellation reasons - -### Trial Conversions - -**Expected behavior:** `isTrialConversion` should only be `true` for `renewal` events - -### Offer Codes Support - -| Store | Support | Notes | -| ---------- | ---------------- | -------------------------------------------------------------- | -| APP_STORE | ✅ Supported | Rarely used (1.3% of events), typically for win-back campaigns | -| PLAY_STORE | ✅ Supported | Heavily used (72.1% of events), complex promotional system | -| STRIPE | ❌ Not supported | Offer codes not available in webhook data | -| PADDLE | 🔜 Coming soon | Support planned | - -### Environment Field - -All stores support both PRODUCTION and SANDBOX environments: - -- **PRODUCTION**: Live, real-money transactions -- **SANDBOX**: Test transactions (TestFlight on iOS, test mode on Stripe, test purchases on Play Store) - -The environment field helps you filter out test transactions from production analytics. - -## Store Event Compatibility Matrix - -Not all events are available for all stores. This table shows which events you can expect from each store based on real webhook data: - -### Event Support by Store - -| Event Name | APP_STORE | PLAY_STORE | STRIPE | PADDLE | -| ----------------------- | --------- | ---------- | ------ | ------ | -| `billing_issue` | ✅ | ✅ | ✅ | 🔜 | -| `cancellation` | ✅ | ✅ | ✅ | 🔜 | -| `expiration` | ✅ | ✅ | ✅ | 🔜 | -| `initial_purchase` | ✅ | ✅ | ✅ | 🔜 | -| `non_renewing_purchase` | ✅ | ✅ | ❌ | 🔜 | -| `product_change` | ✅ | ✅ | ❌ | 🔜 | -| `renewal` | ✅ | ✅ | ✅ | 🔜 | -| `subscription_paused` | ❌ | ✅ | ❌ | 🔜 | -| `uncancellation` | ✅ | ✅ | ✅ | 🔜 | - -✅ = Supported | ❌ = Not supported | 🔜 = Coming soon - -### Period Type Availability by Store - -Different stores support different period types for events: - -#### APP_STORE - -- Supports all period types (TRIAL, INTRO, NORMAL) for most events -- `non_renewing_purchase` only occurs with NORMAL period type - -#### PLAY_STORE - -- Supports all period types (TRIAL, INTRO, NORMAL) for most events -- `renewal` only occurs with NORMAL period type -- `subscription_paused` only occurs with INTRO and NORMAL period types -- **Unique**: Only store that supports `subscription_paused` events - -#### STRIPE - -- Limited period type support compared to mobile app stores -- No INTRO period type support observed -- `expiration` and `renewal` only occur with NORMAL period type -- Does not support `non_renewing_purchase` or `product_change` events - -#### PADDLE - -- Coming soon - full support planned! - -### Store-Specific Considerations - -**Universal Events** (available across APP_STORE, PLAY_STORE, and STRIPE): - -- `billing_issue` -- `cancellation` -- `expiration` -- `initial_purchase` -- `renewal` -- `uncancellation` - -**Store-Specific Events**: - -- `subscription_paused` - Only available from PLAY_STORE -- `non_renewing_purchase` - Not available from STRIPE -- `product_change` - Not available from STRIPE - -### Understanding originalAppUserId - -The `originalAppUserId` field represents the first app user ID associated with a subscription. This field has specific behavior depending on your integration: - - - This field is only set correctly for events generated by users on SDK v4.5.2+. - Events from older SDK versions may omit this field or populate it inconsistently. - - -### Key Points: - -- **What it represents**: The first user ID we saw associated with this subscription (originalTransactionId) -- **Cross-account subscriptions**: Since subscriptions are tied to Apple/Google accounts (not app accounts), users can create multiple accounts in your app while using the same subscription -- **We only store the first one**: If a user creates multiple accounts, we only track the original user ID - -### When this field is populated: - -- **iOS/App Store**: - - If your user ID has been sent to the stores on-device (via StoreKit) - - If your user IDs are UUIDv4 format - - This field will be consistently present for these cases -- **Stripe**: Always populated (we create one for you if not provided) -- **Play Store**: Depends on the integration and user tracking - -### When this field is null: - -- **Legacy users**: Users on old SDK versions -- **Pre-Superwall purchases**: Users who purchased before integrating Superwall -- **No user ID sent**: If user ID was never sent to the store - -### Understanding originalTransactionId - -The `originalTransactionId` is Apple's terminology that acts like a subscription ID. For simplicity and consistency with iOS and other revenue tracking platforms, we use this nomenclature and populate it accordingly for all platforms (Play Store, Stripe, Paddle, etc.). - -- **One per subscription group**: Each user subscription gets one `originalTransactionId` -- **Persists across renewals**: The same `originalTransactionId` is used for all renewals in that subscription -- **Multiple IDs per user**: A single user can have multiple `originalTransactionId` if they: - - Subscribe to products in different subscription groups - - Let a subscription fully expire and re-subscribe later -- **Cross-platform consistency**: While originally an Apple concept, we generate and maintain equivalent IDs for all payment providers to ensure consistent subscription tracking - -### Notes - -- **Currency handling**: - - `price` and `proceeds` are always in USD - - `priceInPurchasedCurrency` is in the currency specified by `currencyCode` - - `exchangeRate` was used to convert from original currency to USD -- **Family Sharing** (App Store only): - - When `isFamilyShare` is true with `price > 0`: These are events for the **family organizer** who pays for the subscription (initial_purchase, renewal, non_renewing_purchase) - - When `isFamilyShare` is true with `price = 0`: These are events for **family members** who use the shared subscription without paying (renewal, uncancellation, billing_issue, etc.) -- **Refunds**: Negative values in `price`, `proceeds`, or `priceInPurchasedCurrency` indicate refunds -- **Transaction IDs**: - - `transactionId`: Unique ID for this specific transaction - - `originalTransactionId`: Subscription ID (first transaction in the subscription group) -- Commission and tax percentages help you understand the revenue breakdown -- **Timestamps**: - - `timestamp` (root level): When the webhook was created - - `ts` (in data): When the actual event occurred - - `purchasedAt`: When the transaction was originally purchased - - ## Integrations - - Currently, we support the following integrations: - - - **Mixpanel**: Track events and user properties in Mixpanel. - - **Slack**: Send notifications to Slack channels. - - **Amplitude**: Product analytics for your app. - - To set up any of these, click on them and fill in the required fields: - - ![](/images/overview-integrations-mp.jpeg) - - Once you've done that, **click** the **Enable** button at the bottom right to save your changes. - -### Mixpanel Integration - Required Fields - -The following fields are required to configure the Mixpanel integration: - -#### Region * -- **Description**: Data residency region for your Mixpanel project -- **Type**: Dropdown selection -- **Required**: Yes - -#### Project Token * -- **Description**: Your Mixpanel project token -- **Type**: Text input -- **Required**: Yes -- **Location**: Mixpanel → Settings → Project Settings → Project Token - -#### Total Spend Property * -- **Description**: The name of the user property to track cumulative spend -- **Type**: Text input -- **Required**: Yes - -#### Sales Reporting * -- **Description**: Whether to report Proceeds after store taxes & fees or Revenue -- **Type**: Dropdown selection -- **Required**: Yes -- **Options**: - - Proceeds (after store taxes & fees) - - Revenue - -### Optional Configuration - -#### Sandbox Project Token -- **Description**: Optional project token for sandbox events -- **Type**: Text input -- **Required**: No -- **Note**: Leave blank to opt out of sandbox event tracking - -### Slack Integration - Required Fields - -The following fields are required to configure the Slack integration: - -#### Required Configuration - -**Webhook Url** * -- **Description**: Your Slack webhook URL for sending messages to a channel -- **Type**: Text input -- **Required**: Yes - -#### Optional Configuration - -**Include Sandbox** -- **Description**: Whether to include sandbox events in Slack notifications -- **Type**: Dropdown selection -- **Required**: No - -**Event Type** -- **Description**: Type of events to send: revenue only or all lifecycle (includes trials, cancellations) -- **Type**: Dropdown selection -- **Required**: No -- **Options**: - - Revenue only - - All lifecycle (includes trials, cancellations) - -### Amplitude Integration - Required Fields - -The following fields are required to configure the Amplitude integration: - -#### Required Configuration - -**Region** * -- **Description**: Data residency region for your Amplitude project -- **Type**: Dropdown selection -- **Required**: Yes - -**Api Key** * -- **Description**: Your Amplitude API key -- **Type**: Text input -- **Required**: Yes - -**Sales Reporting** * -- **Description**: Which revenue value to report in Amplitude -- **Type**: Dropdown selection -- **Required**: Yes - -#### Optional Configuration - -**Sandbox Api Key** -- **Description**: Optional API key for sandbox events -- **Type**: Text input -- **Required**: No -- **Note**: Leave blank to opt out of sandbox event tracking diff --git a/content/docs/home.mdx b/content/docs/home.mdx index ae5ee08c..1d9010de 100644 --- a/content/docs/home.mdx +++ b/content/docs/home.mdx @@ -35,7 +35,7 @@ Superwall gives you the ability to remotely update your purchase screen (a.k.a. ## More Links - + Ask AI about Superwall @@ -51,4 +51,3 @@ Superwall gives you the ability to remotely update your purchase screen (a.k.a. Superwall GitHub - diff --git a/package.json b/package.json index 84b9572f..853acfb2 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,12 @@ "start": "next start" }, "dependencies": { + "@ai-sdk/openai": "^2.0.53", + "@ai-sdk/react": "^2.0.76", "@radix-ui/react-dialog": "^1.1.11", + "ai": "^5.0.76", "class-variance-authority": "^0.7.1", + "cli-progress": "^3.12.0", "clsx": "^2.1.0", "fumadocs-core": "15.3.2", "fumadocs-mdx": "<12.0.0", diff --git a/redirects-map.ts b/redirects-map.ts index 05d77be8..1698ed3e 100644 --- a/redirects-map.ts +++ b/redirects-map.ts @@ -400,7 +400,9 @@ export const fileRedirectsMap = { "expo/quickstart/installation": "expo/quickstart/install", "expo/quickstart/configuring": "expo/quickstart/configure", "installation-via-rn-legacy": "expo/quickstart/install", - "using-the-superwall-delegate": "sdk/guides/using-the-superwall-delegate" + "using-the-superwall-delegate": "sdk/guides/using-the-superwall-delegate", + + "ai": "home?ai=fullscreen", } export const externalRedirectsMap = { diff --git a/scripts/generate-llm-files.ts b/scripts/generate-llm-files.ts index 9ecb3dfd..8c404a12 100644 --- a/scripts/generate-llm-files.ts +++ b/scripts/generate-llm-files.ts @@ -12,6 +12,7 @@ import remarkFollowExport from "../plugins/remark-follow-export" import remarkDirective from "remark-directive" import { remarkInclude } from 'fumadocs-mdx/config'; import remarkSdkFilter from "../plugins/remark-sdk-filter" +import { createProgressBar } from './utils/progress' // 1) Configure your plugins once const processor = remark() @@ -54,6 +55,8 @@ const filters = [ async function main() { await fs.mkdir(OUT, { recursive: true }) const allFiles = await walk(CONTENT) + const interactive = Boolean(process.stdout.isTTY) + const summaries: Array<{ name: string; count: number }> = [] for (const { name, suffix } of filters) { // apply your folder logic; e.g. filePath.includes('/ios/') @@ -67,6 +70,8 @@ async function main() { // build full and index const fullDocs = [] const indexDocs = [`# ${name === 'all' ? 'Superwall' : `Superwall ${name.toUpperCase()}`} SDK\n\n## Docs\n`] + const progressLabel = name === 'all' ? 'LLM all' : `LLM ${name}` + const progress = createProgressBar(progressLabel, subset.length) for (const filePath of subset) { const raw = await fs.readFile(filePath, 'utf8') @@ -83,23 +88,31 @@ async function main() { indexDocs.push( `- [${data.title}](${url}): ${data.description}` ) + progress?.increment() } + progress?.stop() + // write out await fs.writeFile(path.join(OUT, `llms-full${suffix}.txt`), fullDocs.join('\n\n---\n\n'), 'utf8') await fs.writeFile(path.join(OUT, `llms${suffix}.txt`), indexDocs.join('\n') + '\n\n## Optional\n\n- [GitHub](https://github.com/superwall)\n- [Twitter](https://twitter.com/superwall)\n- [Blog](https://superwall.com/blog)\n', 'utf8') - console.log(`✓ Generated llms-full${suffix}.txt & llms${suffix}.txt`) + summaries.push({ name, count: subset.length }) + + if (!interactive) { + console.log(`✓ Generated llms-full${suffix}.txt & llms${suffix}.txt (${subset.length} files)`) + } } -} -console.log('Starting LLM file generation...') + if (interactive) { + const totalDocs = allFiles.length + const variantCount = summaries.length + console.log(`✓ Generated LLM bundles (${variantCount} variants, ${totalDocs} docs)`) + } +} main() - .then(() => { - console.log('✨ Successfully generated all LLM files') - }) .catch(err => { console.error('❌ Error generating LLM files:') console.error(err.stack || err) process.exit(1) - }) \ No newline at end of file + }) diff --git a/scripts/generate-md-files.ts b/scripts/generate-md-files.ts index 92559db2..d351fb81 100644 --- a/scripts/generate-md-files.ts +++ b/scripts/generate-md-files.ts @@ -12,6 +12,7 @@ import remarkFollowExport from "../plugins/remark-follow-export" import remarkDirective from "remark-directive" import { remarkInclude } from 'fumadocs-mdx/config'; import remarkSdkFilter from "../plugins/remark-sdk-filter" +import { createProgressBar } from './utils/progress' // Configure the processor with all plugins const processor = remark() @@ -49,6 +50,8 @@ function getRelativePath(filePath: string): string { async function main() { await fs.mkdir(OUT, { recursive: true }) const allFiles = await walk(CONTENT) + const interactive = Boolean(process.stdout.isTTY) + const progress = createProgressBar('MD export', allFiles.length) for (const filePath of allFiles) { try { @@ -76,21 +79,25 @@ ${data.description ? data.description + '\n\n' : ''}${cleanedContent}` // Write the .md file await fs.writeFile(outputPath, text, 'utf8') - console.log(`✓ Generated ${relativePath}.md`) + progress?.increment() + if (!interactive) { + console.log(`✓ Generated ${relativePath}.md`) + } } catch (error) { console.error(`❌ Error processing ${filePath}:`, error) } } -} -console.log('Starting MD file generation...') + progress?.stop() + + if (interactive) { + console.log(`✓ Exported ${allFiles.length} Markdown files`) + } +} main() - .then(() => { - console.log('✨ Successfully generated all MD files') - }) .catch(err => { console.error('❌ Error generating MD files:') console.error(err.stack || err) process.exit(1) - }) \ No newline at end of file + }) diff --git a/scripts/utils/progress.ts b/scripts/utils/progress.ts new file mode 100644 index 00000000..385dee45 --- /dev/null +++ b/scripts/utils/progress.ts @@ -0,0 +1,24 @@ +import cliProgress from 'cli-progress' + +export type ProgressBar = { + increment: () => void + stop: () => void +} | null + +export function createProgressBar(label: string, total: number): ProgressBar { + if (!process.stdout.isTTY || total === 0) { + return null + } + + const bar = new cliProgress.SingleBar({ + format: `${label.padEnd(18)} [{bar}] {value}/{total} ({percentage}%)`, + barCompleteChar: '█', + barIncompleteChar: '░', + clearOnComplete: true, + hideCursor: true, + linewrap: false, + }) + + bar.start(total, 0) + return bar +} diff --git a/src/ai/message-types.ts b/src/ai/message-types.ts new file mode 100644 index 00000000..b2b0b6bc --- /dev/null +++ b/src/ai/message-types.ts @@ -0,0 +1,10 @@ +import type { UIMessage } from 'ai'; + +export type DocContext = { + url: string; + docId: string; + title?: string; + headings?: string[]; +}; + +export type AppMessage = UIMessage<{ doc?: DocContext }>; diff --git a/src/app/(docs)/[[...slug]]/page.tsx b/src/app/(docs)/[[...slug]]/page.tsx index b124e8ab..7e8ae3dc 100644 --- a/src/app/(docs)/[[...slug]]/page.tsx +++ b/src/app/(docs)/[[...slug]]/page.tsx @@ -1,14 +1,24 @@ import { source } from "@/lib/source" import { DocsPage, DocsBody, DocsDescription, DocsTitle } from "fumadocs-ui/page" -import { notFound } from "next/navigation" +import { notFound, redirect } from "next/navigation" // import { createRelativeLink } from "fumadocs-ui/mdx" import { getMDXComponents } from "@/mdx-components" -import { createElement } from "react" import { Rate } from "@/components/rate" import { CopyPageButton } from "@/components/CopyPageButton" +const ASK_AI_SLUG = 'ai'; + +function isAskAISlug(slug?: string[]) { + return Array.isArray(slug) && slug.length === 1 && slug[0] === ASK_AI_SLUG; +} + export default async function Page(props: { params: Promise<{ slug?: string[] }> }) { const params = await props.params + + if (isAskAISlug(params.slug)) { + redirect('/?ai=fullscreen') + } + const page = source.getPage(params.slug) if (!page) notFound() @@ -20,7 +30,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> // Get current path for copy button const currentPath = params.slug ? `/${params.slug.join('/')}` : '/'; - const disabledPages = ['/home', '/support', '/ios', '/android', '/flutter', '/expo', '/dashboard']; + const disabledPages = ['/home', '/docs/support', '/ios', '/android', '/flutter', '/expo', '/dashboard']; const shouldDisableButton = disabledPages.includes(currentPath); return ( @@ -52,6 +62,12 @@ export async function generateStaticParams() { export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) { const params = await props.params + if (isAskAISlug(params.slug)) { + return { + title: 'Ask AI', + description: 'Get instant answers to your questions about Superwall.', + } + } const page = source.getPage(params.slug) if (!page) notFound() diff --git a/src/app/ai/AIPageContent.tsx b/src/app/ai/AIPageContent.tsx index aeb78c6e..a7a78602 100644 --- a/src/app/ai/AIPageContent.tsx +++ b/src/app/ai/AIPageContent.tsx @@ -1,29 +1,59 @@ 'use client'; -import AskAI from "@/components/AskAI"; -import { useSearchParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense } from 'react'; -export default function AIPageContent() { +import { ChatView } from '@/components/ChatView'; +import { useFumadocsSidebarWidth } from '@/hooks/useFumadocsSidebarWidth'; + +function AIPageContentInner() { const searchParams = useSearchParams(); const router = useRouter(); - const [initialQuery, setInitialQuery] = useState(null); + const [initialQuery, setInitialQuery] = useState<{ id: number; value: string } | null>(null); + const sidebarWidth = useFumadocsSidebarWidth(); useEffect(() => { const searchQuery = searchParams.get('search'); - if (searchQuery) { - try { - const decoded = decodeURIComponent(searchQuery); - setInitialQuery(decoded); - // Clear the URL parameter - const url = new URL(window.location.href); - url.searchParams.delete('search'); - router.replace(url.pathname + url.search, { scroll: false }); - } catch { - // Fail silently if malformed + if (!searchQuery) return; + + try { + const decoded = decodeURIComponent(searchQuery); + if (decoded.trim().length > 0) { + setInitialQuery({ id: Date.now(), value: decoded }); } + } catch { + // ignore malformed queries } + + const url = new URL(window.location.href); + url.searchParams.delete('search'); + router.replace(url.pathname + url.search, { scroll: false }); }, [searchParams, router]); - return ; -} \ No newline at end of file + const computedWidth = sidebarWidth > 0 ? `calc(100vw - ${sidebarWidth}px)` : '100vw'; + + return ( +
+
+ +
+
+ ); +} + +export default function AIPageContent() { + return ( + + + + ); +} diff --git a/src/app/ai/page.tsx b/src/app/ai/page.tsx index a8035cad..3e9d4af2 100644 --- a/src/app/ai/page.tsx +++ b/src/app/ai/page.tsx @@ -1,22 +1,29 @@ -import { DocsPage, DocsBody, DocsDescription, DocsTitle } from "fumadocs-ui/page"; -import { Suspense } from "react"; -import AIPageContent from "./AIPageContent"; +'use client'; -export const metadata = { - title: "Ask AI", - description: "Get instant answers to your questions about Superwall.", -}; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { ChatView } from '@/components/ChatView'; export default function AIPage() { + // const router = useRouter(); + + // Check if we're at /docs/ai and redirect to /docs/home?ai=true + // useEffect(() => { + // if (typeof window !== 'undefined' && window.location.pathname === '/docs/ai') { + // router.replace('/docs/home?ai=true'); + // } + // }, [router]); + + // If we're at /ai, show fullscreen chat return ( - - Ask AI - Get instant answers to your questions about Superwall. - - Loading...}> - - - - +
+ {/* Fullscreen chat view - goes up to the left sidebar */} + +
); -} \ No newline at end of file +} diff --git a/src/app/api/ai/route.ts b/src/app/api/ai/route.ts new file mode 100644 index 00000000..c2905618 --- /dev/null +++ b/src/app/api/ai/route.ts @@ -0,0 +1,249 @@ +import { streamText } from 'ai'; +import { createOpenAI } from '@ai-sdk/openai'; + +export const runtime = 'nodejs'; + +const systemPrompt = `# Superwall Docs AI + +Act as a Superwall expert with comprehensive knowledge of the SDK, dashboard, and product. Provide concise and accurate answers based on the markdown-formatted documentation and help center knowledge base. Use the provided documentation as your ONLY source as it could change frequently. If uncertain about an answer, state that clearly. Always include a link to the source file used to find the information at the end of your response. + +## Steps +1. **Analyze the Question**: Understand what specific information or clarification the question seeks about Superwall. +2. **Locate Information**: Refer to the markdown-formatted documentation to find relevant details. +3. **Craft a Response**: Provide a concise and precise answer to the question. +4. **Cite Source**: On a new line, append a link to the specific file or section of the documentation that supports your answer, markdown formatted as \`[link text](url)\`. + +## Input Format +The documentation is markdown-formatted: +- The current page that the user is on is contained by the following tags: \`{{BEGIN CURRENT PAGE CONTEXT}}\` and \`{{END CURRENT PAGE CONTEXT}}\`. (note: the user's query may or may not be directly related to the current page) +- The full documentation for the selected SDK (or none if not selected) and the dashboard are contained by the following tags: \`{{BEGIN DOCS CONTEXT}}\` and \`{{END DOCS CONTEXT}}\`. + +## Output Format + +- Provide the answer as a short paragraph for clarity. +- If unsure, write: "I'm not sure about this. Please check with further resources." +- Include a link to the documentation file used for the answer at the end of the response. + +## Examples + +**Example 1** + +**Input**: can superwall tell me if a user has cancelled a subscription or their subscription has run out? + +**Response**: Yes, Superwall can track a user's subscription status. According to the docs, Superwall exposes a published property \`Superwall.shared.subscriptionStatus\` that tracks whether a subscription is active or expired. +By default, Superwall handles all subscription-related logic including: +- Tracking when subscriptions become active +- Tracking when subscriptions expire +- Checking the local receipt for verification + +You can access this information through the \`subscriptionStatus\` property to determine if a user's subscription has ended or been cancelled. + +[Tracking Subscription State](https://superwall.com/docs/tracking-subscription-state) + +## Context + +{{BEGIN DOCS CONTEXT}} +{{docs}} +{{END DOCS CONTEXT}} + +The user has selected SDK: {{SELECTED_SDK}} + +{{ BEGIN CURRENT PAGE CONTEXT }} +{{CURRENT_PAGE_CONTEXT}} +{{ END CURRENT PAGE CONTEXT }} +`; + +const defaultOpenAIModel = 'gpt-5-nano'; +const MAX_CONVERSATION_EXCHANGES = 3; // Number of user/assistant exchanges to include + +// Helper function to load text file from public docs directory +async function loadTextFile(fileName: string): Promise { + try { + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://superwall.com'; + const url = `${baseUrl}/docs/${fileName}`; + + const response = await fetch(url, { + headers: { + 'User-Agent': 'Superwall-Docs-AI/1.0', + Accept: 'text/plain', + }, + }); + + if (!response.ok) { + console.warn(`Failed to load ${fileName}: HTTP ${response.status} from ${url}`); + return null; + } + + const content = await response.text(); + return content; + } catch (error) { + console.error(`Error loading ${fileName}:`, error); + return null; + } +} + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { messages, sdk, currentPagePath } = body as { + messages: Array; + sdk?: string; + currentPagePath?: string; + }; + + // Basic validation + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return new Response('Invalid messages format', { status: 400 }); + } + + // Get the last user message to ensure it's valid + const lastMessage = messages[messages.length - 1]; + if (!lastMessage || lastMessage.role !== 'user') { + return new Response('Last message must be from user', { status: 400 }); + } + + // Extract text from a message (handle UIMessage format with parts array) + const extractMessageText = (message: any): string => { + let text = ''; + + // Try parts array first (UIMessage format from AI SDK) + if (message.parts && Array.isArray(message.parts)) { + text = message.parts + .map((part: any) => (part.type === 'text' ? part.text : '')) + .join(''); + } + // Fall back to content field + else if (message.content) { + if (typeof message.content === 'string') { + text = message.content; + } else if (Array.isArray(message.content)) { + text = message.content + .map((part: any) => (part.type === 'text' ? part.text : '')) + .join(''); + } + } + + return text.trim(); + }; + + // Validate last message has content + const lastUserMessageText = extractMessageText(lastMessage); + if (!lastUserMessageText) { + return new Response('Message cannot be empty', { status: 400 }); + } + + // Get the last N exchanges (user + assistant pairs) for context + // We want to include up to MAX_CONVERSATION_EXCHANGES exchanges + const conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }> = []; + + // Work backwards through messages to collect exchanges + let exchangeCount = 0; + for (let i = messages.length - 1; i >= 0 && exchangeCount < MAX_CONVERSATION_EXCHANGES; i--) { + const msg = messages[i]; + const text = extractMessageText(msg); + + if (!text) continue; // Skip empty messages + + if (msg.role === 'user' || msg.role === 'assistant') { + conversationHistory.unshift({ + role: msg.role, + content: text, + }); + + // Count exchanges (user message = start of exchange) + if (msg.role === 'user') { + exchangeCount++; + } + } + } + + // Get OpenAI API key from env + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return new Response('OpenAI API key not configured', { status: 500 }); + } + + // Load documentation files + let filesToLoad = ['llms-full-dashboard.txt']; + + // Add SDK-specific file if SDK is specified + const validSdks = ['ios', 'android', 'flutter', 'expo']; + if (sdk && validSdks.includes(sdk.toLowerCase())) { + filesToLoad.push(`llms-full-${sdk.toLowerCase()}.txt`); + } + + const docsSections = []; + for (const fileName of filesToLoad) { + const sectionContent = await loadTextFile(fileName); + if (sectionContent) { + docsSections.push(sectionContent); + } + } + + if (docsSections.length === 0) { + console.error('No docs sections found'); + return new Response('Documentation not available', { status: 500 }); + } + + const docsContent = docsSections.join('\n\n---\n\n'); + let enhancedSystemPrompt = systemPrompt.replace('{{docs}}', docsContent); + + // Fetch current page context if the keyword exists and path is provided + if (systemPrompt.includes('{{CURRENT_PAGE_CONTEXT}}')) { + let currentPageContext = ''; + + if (currentPagePath) { + // Construct the path to the markdown file + // currentPagePath is like "/ios/quickstart" -> fetch "ios/quickstart.md" + const cleanPath = currentPagePath.startsWith('/') + ? currentPagePath.slice(1) + : currentPagePath; + const mdFileName = `${cleanPath}.md`; + + try { + const pageContent = await loadTextFile(mdFileName); + if (pageContent) { + currentPageContext = `\n\n# Current Page Context\n\nThe user is currently viewing the page at path: ${currentPagePath}\n\n${pageContent}`; + } else { + console.warn(`Failed to load current page content: ${mdFileName}`); + } + } catch (error) { + console.error(`Error loading current page context for ${mdFileName}:`, error); + } + } + + enhancedSystemPrompt = enhancedSystemPrompt.replace('{{CURRENT_PAGE_CONTEXT}}', currentPageContext); + } + + // Inject SDK selection text + if (systemPrompt.includes('{{SELECTED_SDK}}')) { + const sdkText = sdk && validSdks.includes(sdk.toLowerCase()) + ? `The user has selected SDK: ${sdk.toLowerCase()}` + : 'The user has not selected any specific SDK'; + + enhancedSystemPrompt = enhancedSystemPrompt.replace('{{SELECTED_SDK}}', sdkText); + } + + // Stream the response using AI SDK + const openai = createOpenAI({ apiKey }); + + const result = streamText({ + model: openai(defaultOpenAIModel), + system: enhancedSystemPrompt, + messages: conversationHistory, + providerOptions: { + openai: { + reasoningEffort: "minimal", // "minimal" | "low" | "medium" | "high" + }, + }, + }); + + return result.toUIMessageStreamResponse(); + } catch (error) { + console.error('AI route error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 00000000..3d060e62 --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from 'next/server'; + +const API_URL = + process.env.NODE_ENV === 'development' + ? 'http://localhost:8787' + : 'https://docs-ai-api.superwall.com'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + // Forward the request to the docs-ai-api worker + const response = await fetch(`${API_URL}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + // Return the streaming response + return new Response(response.body, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') || 'text/plain', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } catch (error) { + console.error('Chat API proxy error:', error); + return new Response( + JSON.stringify({ error: 'Failed to process chat request' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ); + } +} diff --git a/src/app/global.css b/src/app/global.css index f567daaf..66ac7eaf 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -20,6 +20,7 @@ */ --radius-lg: 0.75rem; --spacing: 0.3rem; + --sw-chat-width: 0px; } .dark { @@ -55,6 +56,61 @@ --tw-prose-counters: var(--color-fd-muted-foreground); } +body { + padding-right: var(--sw-chat-width); + transition: padding-right 0.3s ease; +} + +@media (max-width: 1023px) { + body { + padding-right: 0 !important; + } +} + +html.chat-open { + --fd-toc-width: 0px; + --fd-tocnav-height: 0px; +} + +#nd-toc, +#nd-tocnav { + transition: opacity 0.3s ease, transform 0.3s ease; + will-change: opacity, transform; +} + +#nd-toc { + overflow: hidden; + will-change: transform; + width: var(--fd-toc-width, 286px); + max-width: var(--fd-toc-width, 286px); + flex: 0 0 var(--fd-toc-width, 286px); +} + +#nd-toc > div { + transition: padding 0.3s ease; +} + +html.chat-open .xl\[--fd-toc-width\:286px\] { + --fd-toc-width: 0px !important; +} + +html.chat-open #nd-toc, +html.chat-open #nd-tocnav { + opacity: 0; + pointer-events: none; +} +html.chat-open #nd-toc { + transform: translateX(12px); + width: 0 !important; + max-width: 0 !important; + flex: 0 0 0 !important; + margin: 0 !important; +} + +html.chat-open #nd-toc > div { + padding: 0 !important; +} + /* colored pill on first column for */ .fd-param tbody td:first-child code { background: #74F8F020; @@ -74,4 +130,16 @@ [data-role="expand"], svg[data-icon="true"] { /* ← arrow in current Fumadocs build */ pointer-events: none; -} \ No newline at end of file +} + +/* Wiggle animation for chat sidebar */ +@keyframes wiggle { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-8px); } + 50% { transform: translateX(0); } + 75% { transform: translateX(-4px); } +} + +.animate-wiggle { + animation: wiggle 0.5s ease-in-out; +} diff --git a/src/app/layout.config.tsx b/src/app/layout.config.tsx index 5ac184ea..4b6b1e0c 100644 --- a/src/app/layout.config.tsx +++ b/src/app/layout.config.tsx @@ -1,5 +1,5 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; -import { Activity, Book, BookOpen, Github, Twitter, HelpCircle, Sparkles } from 'lucide-react'; +import { Activity, Book, BookOpen, Github, Twitter, HelpCircle, MessageCircle } from 'lucide-react'; import { DiscordIcon } from '@/components/DiscordIcon'; /** @@ -31,9 +31,9 @@ export const baseOptions: BaseLayoutProps = { // }, { text: 'Ask AI', - url: '/ai', + url: '/docs/ai', active: 'none', - icon: , + icon: , }, { text: 'Help Center', @@ -43,7 +43,7 @@ export const baseOptions: BaseLayoutProps = { }, { text: 'Support', - url: '/support', + url: '/docs/support', active: 'none', icon: , }, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d2563d0c..05c079b5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,10 @@ import './global.css'; import { RootProvider } from 'fumadocs-ui/provider'; import { Inter } from 'next/font/google'; -import type { ReactNode } from 'react'; +import { Suspense, type ReactNode } from 'react'; import { SearchDialogWrapper as SearchDialog } from '../components/SearchDialog'; import { GlobalScripts } from '../components/GlobalScripts'; +import { ChatWidget } from '../components/ChatWidget'; const inter = Inter({ subsets: ['latin'], @@ -25,6 +26,9 @@ export default function Layout({ children }: { children: ReactNode }) { }} > {children} + + + diff --git a/src/app/support/page.tsx b/src/app/support/page.tsx new file mode 100644 index 00000000..59c875ed --- /dev/null +++ b/src/app/support/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function SupportPage() { + const router = useRouter(); + const [isLoggedIn, setIsLoggedIn] = useState(true); + + // Fetch user session to check if logged in + useEffect(() => { + fetch('/api/auth/session') + .then((res) => res.json()) + .then((data) => { + if (data && typeof data.isLoggedIn === 'boolean') { + setIsLoggedIn(data.isLoggedIn); + } + }) + .catch((err) => { + console.error('Failed to fetch session:', err); + // Default to logged in on error + setIsLoggedIn(true); + }); + }, []); + + useEffect(() => { + // Check if in development mode + const isDev = process.env.NEXTJS_ENV === 'development' || process.env.NODE_ENV === 'development'; + + // Always allow in dev, otherwise check auth + if (!isDev && !isLoggedIn) { + // Redirect to login + window.location.href = '/api/auth/login'; + return; + } + + // Wait for Pylon to load and then open it + const checkPylon = setInterval(() => { + if (typeof window.Pylon === 'function') { + clearInterval(checkPylon); + try { + window.Pylon('show'); + // Redirect back to previous page or home + router.back(); + } catch (error) { + console.error('Error opening Pylon:', error); + router.push('/'); + } + } + }, 100); + + // Timeout after 5 seconds + const timeout = setTimeout(() => { + clearInterval(checkPylon); + console.error('Pylon failed to load'); + router.push('/'); + }, 5000); + + return () => { + clearInterval(checkPylon); + clearTimeout(timeout); + }; + }, [router, isLoggedIn]); + + return ( +
+
+

Opening support chat...

+
+
+ ); +} diff --git a/src/components/AskAI.tsx b/src/components/AskAI.tsx deleted file mode 100644 index 8451991e..00000000 --- a/src/components/AskAI.tsx +++ /dev/null @@ -1,574 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef, ComponentProps } from 'react'; -import { Loader2 as Loader, Sparkles, RotateCcw, CornerDownLeft, X, ThumbsUp, ThumbsDown, ChevronDown } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import { cn } from 'fumadocs-ui/utils/cn'; -import { useLocalStorage } from '@/hooks/useLocalStorage'; - -const API_URL = - typeof window !== 'undefined' && window.location.hostname.includes('localhost') - ? 'http://localhost:8787' - : 'https://docs-ai-api.superwall.com'; - -const USE_DUMMY_API = false; -const FORCE_ERROR_STATE = false; - -const SDK_OPTIONS = [ - { value: '', label: 'None' }, - { value: 'ios', label: 'iOS' }, - { value: 'android', label: 'Android' }, - { value: 'flutter', label: 'Flutter' }, - { value: 'expo', label: 'Expo' }, -] as const; - -const DUMMY_MARKDOWN = ` -### Lorem Ipsum - -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -`; - -type ChatHistoryItem = { - id: string; - question: string; - answer: string; - isError?: boolean; - isIncomplete?: boolean; - feedback?: { - rating: 'positive' | 'negative'; - comment?: string; - submitted?: boolean; - }; -}; - -interface AskAIProps extends ComponentProps<'div'> { - initialQuery?: string | null; -} - -export default function AskAI({ - className, - initialQuery, - ...props -}: AskAIProps) { - const [query, setQuery] = useState(''); - const [answerMd, setAnswerMd] = useState(null); - const [loading, setLoading] = useState(false); - const [history, setHistory] = useLocalStorage('superwall-ai-chat-history', []); - const [selectedSdk, setSelectedSdk] = useLocalStorage('superwall-ai-selected-sdk', ''); - const [showSdkDropdown, setShowSdkDropdown] = useState(false); - const [userEmail, setUserEmail] = useState(null); - - // Effect to migrate old data without IDs - useEffect(() => { - setHistory(prev => prev.map((item, index) => - item.id ? item : { ...item, id: `migrated-${Date.now()}-${index}` } - )); - }, []); - - // Effect to fetch user session - useEffect(() => { - fetch('/api/auth/session') - .then(res => res.json()) - .then(data => { - if (data.isLoggedIn && data.userInfo?.email) { - setUserEmail(data.userInfo.email); - } - }) - .catch(err => console.error('Failed to fetch session:', err)); - }, []); - const [currentQuestion, setCurrentQuestion] = useState(null); - const [retryQuery, setRetryQuery] = useState(null); - const [showAutofillPill, setShowAutofillPill] = useState(false); - const [feedbackState, setFeedbackState] = useState<{[key: string]: {showInput: boolean, comment: string}}>({}); - const inputRef = useRef(null); - const dropdownRef = useRef(null); - - const [focused, setFocused] = useState(false); - - const removeHistoryItem = (index: number) => { - setHistory(prev => prev.filter((_, i) => i !== index)); - }; - - const selectSdk = (sdkValue: string) => { - setSelectedSdk(sdkValue); - setShowSdkDropdown(false); - }; - - const getSelectedSdk = () => { - const found = SDK_OPTIONS.find(opt => opt.value === selectedSdk); - return found || SDK_OPTIONS[0]; // Default to "None" (first option) - }; - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setShowSdkDropdown(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const retryQuestion = (question: string, index: number) => { - // Remove the failed item and set retry flag - removeHistoryItem(index); - setRetryQuery(question); - }; - - const handleFeedback = (cardId: string, rating: 'positive' | 'negative') => { - const item = history.find(h => h.id === cardId); - - // Don't allow changes if feedback has been submitted - if (item?.feedback?.submitted) { - return; - } - - // If clicking the same rating again, undo it (only if not submitted) - if (item?.feedback?.rating === rating && !item.feedback.comment) { - setHistory(prev => prev.map(item => - item.id === cardId ? { ...item, feedback: undefined } : item - )); - setFeedbackState(prev => ({ - ...prev, - [cardId]: { showInput: false, comment: '' } - })); - return; - } - - // Update the rating (allow switching between positive/negative) - setHistory(prev => prev.map(item => - item.id === cardId ? { ...item, feedback: { rating, comment: feedbackState[cardId]?.comment || '' } } : item - )); - - // Show comment input for this item - setFeedbackState(prev => ({ - ...prev, - [cardId]: { showInput: true, comment: '' } - })); - - // Don't send to API yet - wait for user to submit - }; - - const submitFeedbackComment = (cardId: string) => { - const comment = feedbackState[cardId]?.comment || ''; - const item = history.find(h => h.id === cardId); - - if (!item?.feedback) return; - - // Update the history item with the comment and mark as submitted - setHistory(prev => prev.map(item => - item.id === cardId && item.feedback ? - { ...item, feedback: { ...item.feedback, comment, submitted: true } } : item - )); - - // Hide the input - setFeedbackState(prev => ({ - ...prev, - [cardId]: { ...prev[cardId], showInput: false } - })); - - // Send feedback with comment to API - sendFeedbackToAPI(cardId, item.feedback.rating, comment); - }; - - const updateFeedbackComment = (cardId: string, comment: string) => { - setFeedbackState(prev => ({ - ...prev, - [cardId]: { ...prev[cardId], comment } - })); - }; - - const sendFeedbackToAPI = async (cardId: string, rating: 'positive' | 'negative', comment: string) => { - const item = history.find(h => h.id === cardId); - if (!item) return; - - try { - const response = await fetch('/docs/api/feedback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: 'ai', - question: item.question, - answer: item.answer, - rating, - comment: comment || undefined, - email: userEmail || undefined, - }), - }); - - if (!response.ok) { - console.error('Failed to send feedback:', response.status, response.statusText); - const errorText = await response.text(); - console.error('Error response:', errorText); - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - console.log('Feedback sent successfully:', result); - } catch (error) { - console.error('Failed to send feedback:', error); - // Could enhance this with toast notifications in the future - } - }; - - // Effect to handle retry after history state update - useEffect(() => { - if (retryQuery) { - runQuery(retryQuery); - setRetryQuery(null); - } - }, [retryQuery, history]); - - // Effect to handle initial query from URL parameter - useEffect(() => { - if (initialQuery && !loading && !currentQuestion) { - setShowAutofillPill(true); - runQuery(initialQuery); - } - }, [initialQuery]); - - // handy “reset” helper - const reset = () => { - setQuery(''); - setAnswerMd(null); - setCurrentQuestion(null); - inputRef.current?.focus(); - }; - - const runQuery = async (overrideQuery?: string) => { - const q = overrideQuery || query.trim(); - if (!q || loading) return; - setCurrentQuestion(q); - if (!overrideQuery) { - setQuery(''); - inputRef.current?.blur(); - setFocused(false); - } - setLoading(true); - setAnswerMd(null); - - if (USE_DUMMY_API) { - await new Promise((r) => setTimeout(r, 1000)); - if (FORCE_ERROR_STATE) { - const errorId = Date.now().toString(); - setHistory(prev => [{ - id: errorId, - question: q, - answer: '**Error** – please try again.', - isError: true - }, ...prev]); - - } else { - const responseId = Date.now().toString(); - setHistory(prev => [{ - id: responseId, - question: q, - answer: DUMMY_MARKDOWN - }, ...prev]); - - } - setLoading(false); - setCurrentQuestion(null); - return; - } - - try { - const res = await fetch(`${API_URL}/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message: q, - sdks: selectedSdk ? [selectedSdk] : undefined, - email: userEmail || undefined - }), - }); - if (!res.ok) throw new Error('Bad response'); - const text = await res.text(); - const responseId = Date.now().toString(); - setHistory(prev => [{ - id: responseId, - question: q, - answer: text - }, ...prev]); - - } catch (err) { - const errorId = Date.now().toString(); - setHistory(prev => [{ - id: errorId, - question: q, - answer: '**Error** – please try again.', - isError: true - }, ...prev]); - console.error(err); - - } finally { - setLoading(false); - setCurrentQuestion(null); - setShowAutofillPill(false); - } - }; - - // ⏎ to run - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') { - e.preventDefault(); - runQuery(); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }); - - return ( -
- {/* Search input with SDK selector */} -
- {/* SDK selector */} -
- - - {showSdkDropdown && ( -
- {SDK_OPTIONS.map((option) => ( - - ))} -
- )} -
- - {/* Search input */} -
- setFocused(true)} - onBlur={() => setFocused(false)} - ref={inputRef} - value={query} - onChange={(e) => setQuery(e.target.value)} - placeholder={answerMd ? 'Ask another question…' : 'Ask anything about Superwall...'} - className={cn( - 'w-full rounded-[var(--radius-lg)] border bg-transparent px-3 pl-8 pr-8 h-[44px]', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-primary', - loading && 'opacity-50 cursor-not-allowed' - )} - style={{ borderRadius: 'var(--radius-lg)' }} - disabled={loading} - /> - - {focused && ( - - )} -
-
- - {currentQuestion && ( -
-
-
- {currentQuestion} - {showAutofillPill && ( - - AUTOFILLED - - )} -
- {!loading && ( - - )} -
-
-
- {loading ? ( -

- Loading… -

- ) : ( - {history[0].answer} - )} -
-
- )} - - {history.map((item, idx) => ( -
-
- {item.question} - -
-
-
- {item.isError ? ( -
-
- {item.answer} -
- -
- ) : ( -
- {/* Content with feedback buttons pinned to bottom right */} -
-
- {item.answer} -
- - {/* Feedback UI - pinned to bottom right corner */} -
- - -
-
- - {/* Feedback comment input */} - {feedbackState[item.id]?.showInput && ( -
-

- Optional feedback (helps improve AI responses) -

-
-