diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9d1d07f --- /dev/null +++ b/.env.example @@ -0,0 +1,62 @@ +# ───────────────────────────────────────────────────────────────────────────── +# IB EconGraph AI — environment variables +# +# Everything here is OPTIONAL. Without any of it the app runs fully free & +# local (BYOK AI keys are entered in the UI, data lives in localStorage). +# Configure these only if you want accounts, cloud sync, and the Supporter +# plan on your own deployment. See docs/BACKEND_SETUP.md for the full guide. +# ───────────────────────────────────────────────────────────────────────────── + +# ── Client (bundled into the frontend by Vite — safe to expose) ───────────── +# Supabase project URL + publishable key (sb_publishable_…, Project Settings → +# API Keys). Low-privilege, safe in the client bundle. Enables sign-in/sync/share. +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= + +# ── Server (Vercel project env vars — NEVER commit real values) ───────────── +# Supabase secret key (sb_secret_…, Project Settings → API Keys). Bypasses RLS; +# server only — Supabase rejects it if sent from a browser. +SUPABASE_URL= +SUPABASE_SECRET_KEY= + +# ── Hosted AI (Supporter plan) ────────────────────────────────────────────── +# The server generates diagrams for supporters using ONE of three backends. +# They are tried in the order below; the first one that is configured wins. +# +# 1) Vertex AI express mode: a single API key, no service account, so it works +# on serverless out of the box. Create one in the Google Cloud console under +# "Gemini Enterprise Agent Platform" (the 2026 rebrand of Vertex AI), express +# mode. NOTE: creating a Vertex API key requires a Google Cloud organization; +# a personal @gmail.com account with no org is blocked and should use (2). +VERTEX_API_KEY= +# +# 2) Vertex AI with a project id (and optional location, default "global"). +# Locally this authenticates with your gcloud Application Default +# Credentials: run `gcloud auth application-default login` once. On hosts +# without gcloud (e.g. Vercel), also paste a service-account key JSON as a +# single line into GOOGLE_SERVICE_ACCOUNT_JSON. +GOOGLE_CLOUD_PROJECT= +GOOGLE_CLOUD_LOCATION=global +GOOGLE_SERVICE_ACCOUNT_JSON= +# +# 3) Gemini Developer API (Google AI Studio): the simplest fully-free option. +# Get a key at https://aistudio.google.com/apikey +GEMINI_API_KEY= + +# Shared hosted-AI settings, applied to whichever backend above is active. +HOSTED_AI_MONTHLY_LIMIT=150 +HOSTED_AI_MODEL=gemini-2.5-flash + +# Polar billing (https://polar.sh). Use POLAR_SERVER=sandbox while testing. +POLAR_ACCESS_TOKEN= +POLAR_WEBHOOK_SECRET= +POLAR_PRODUCT_ID_MONTHLY= +POLAR_PRODUCT_ID_YEARLY= +POLAR_SERVER=production + +# Public URL of the deployment, used to pin checkout redirects to a canonical +# domain. Optional: if left blank the server uses the incoming request's origin +# (your real domain), which is correct for most setups. Set it only to force a +# specific domain, e.g. when *.vercel.app preview aliases should redirect to +# your primary URL. Example: https://your-app.vercel.app +APP_URL= diff --git a/.github/workflows/db-keepalive.yml b/.github/workflows/db-keepalive.yml new file mode 100644 index 0000000..168f6f1 --- /dev/null +++ b/.github/workflows/db-keepalive.yml @@ -0,0 +1,50 @@ +name: DB keepalive + +# Supabase free-tier projects pause after 7 days of inactivity. This makes a +# cheap read against the database every ~5 days to keep it awake, leaving a +# safe margin under the 7-day pause window. +# +# Reuses the same repository secrets as the supporters workflow +# (Settings, then Secrets and variables, then Actions): +# SUPABASE_URL your Supabase project URL +# SUPABASE_SECRET_KEY the Supabase secret key (sb_secret_...) + +on: + schedule: + # Runs on days 1,6,11,16,21,26,31 -> a gap of at most 5 days, always under 7. + - cron: '0 6 */5 * *' + workflow_dispatch: {} + +concurrency: + group: db-keepalive + cancel-in-progress: false + +jobs: + ping: + runs-on: ubuntu-latest + steps: + - name: Ping the database + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }} + run: | + if [ -z "$SUPABASE_URL" ] || [ -z "$SUPABASE_SECRET_KEY" ]; then + echo "Supabase secrets are not set; nothing to ping." + exit 0 + fi + # One-row read via PostgREST. The secret key bypasses RLS, so this is a + # trivial query that still counts as real database activity. + # Bound the request: without a timeout a hung connection would stall + # the job until GitHub's 6h default runner limit. + code=$(curl -s -o /dev/null -w '%{http_code}' \ + --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 5 \ + "$SUPABASE_URL/rest/v1/profiles?select=id&limit=1" \ + -H "apikey: $SUPABASE_SECRET_KEY" \ + -H "Authorization: Bearer $SUPABASE_SECRET_KEY") + echo "Supabase responded: HTTP $code" + # 200 (rows) and 206 (partial content) both mean the query was served. + if [ "$code" != "200" ] && [ "$code" != "206" ]; then + echo "Unexpected status $code; keepalive may have failed." + exit 1 + fi + echo "Keepalive ping OK." diff --git a/.github/workflows/update-supporters.yml b/.github/workflows/update-supporters.yml new file mode 100644 index 0000000..e883ae2 --- /dev/null +++ b/.github/workflows/update-supporters.yml @@ -0,0 +1,53 @@ +name: Update supporters + +# Refreshes the Supporters block in README.md from the database on a schedule. +# Requires two repository secrets (Settings → Secrets and variables → Actions): +# SUPABASE_URL — your Supabase project URL +# SUPABASE_SECRET_KEY — the Supabase secret key (sb_secret_…) + +on: + schedule: + - cron: '0 6 * * 1' # every Monday at 06:00 UTC + workflow_dispatch: {} # allow manual runs from the Actions tab + +permissions: + contents: write + +# Never run two updates at once. +concurrency: + group: update-supporters + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Don't leave a contents:write token in .git/config while `npm ci` + # runs arbitrary dependency install scripts. The push below passes + # the token explicitly instead. + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - name: Refresh supporters block + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }} + run: node scripts/update-supporters.mjs + - name: Commit if the README changed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ -n "$(git status --porcelain README.md)" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "chore: refresh supporters list" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}" + else + echo "No supporter changes to commit." + fi diff --git a/.gitignore b/.gitignore index ffc3818..c6da18d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ lerna-debug.log* # OS files .DS_Store Thumbs.db +.vercel +.env* +!.env.example diff --git a/App.tsx b/App.tsx index f8db4a6..ade0277 100644 --- a/App.tsx +++ b/App.tsx @@ -1,10 +1,31 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { generateDiagramData, hasApiKey } from './services/ai'; +import { getAIProvider } from './services/aiProvider'; +import { useAuth } from './services/auth'; +import { useCloudSync } from './services/useCloudSync'; +import { recordTombstones, clearTombstones, fetchCloudIds } from './services/sync'; +import { + GUEST_SCOPE, + initLocalStore, + requestPersistentStorage, + readScope, + writeGraphs, + writeProjects, + scopeHasContent, + adoptScope, + decideGuestAdoption, +} from './services/localStore'; import DiagramRenderer from './components/DiagramRenderer'; import LandingPage from './components/LandingPage'; import HomePage from './components/HomePage'; import SettingsPage from './components/SettingsPage'; +import PricingPage from './components/PricingPage'; +import ComparePage from './components/ComparePage'; +import { PrivacyPage, TermsPage } from './components/LegalPages'; +import SharedViewPage from './components/SharedViewPage'; +import ShareModal from './components/ShareModal'; +import CloudHistoryModal from './components/CloudHistoryModal'; import ToolbarLeft from './components/ToolbarLeft'; import ToolbarRight from './components/ToolbarRight'; import ComponentLibrary from './components/ComponentLibrary'; @@ -13,17 +34,19 @@ import { usePortalTooltip } from './components/usePortalTooltip'; import { DiagramData, INITIAL_DIAGRAM, EMPTY_DIAGRAM, Graph, Project, Message, EditorTool, EditorSettings, ComponentTemplate } from './types'; import { Loader2, Send, Plus, MessageSquare, BarChart2, - Trash2, Menu, History, RotateCcw, RotateCw, FolderOpen, ChevronLeft, Grid3X3, AlertTriangle, Settings + Trash2, Menu, History, RotateCcw, RotateCw, FolderOpen, ChevronLeft, Grid3X3, AlertTriangle, Settings, + Share2, CloudDownload } from 'lucide-react'; const generateId = () => uuidv4(); +// Diagrams and projects are stored per account (see services/localStore.ts). +// These keys are editor preferences, which are deliberately shared across +// accounts on the same browser: they describe the tool, not anyone's work. const STORAGE_KEYS = { - graphs: 'econgraph_graphs', - projects: 'econgraph_projects', settings: 'econgraph_settings', specialColors: 'econgraph_special_colors', - standardColors: 'econgraph_standard_colors' + standardColors: 'econgraph_standard_colors', }; const DEFAULT_STANDARD_COLORS = [ @@ -55,18 +78,28 @@ const PROJECT_COLORS = [ '#ec4899', // Pink ]; -type ViewType = 'landing' | 'home' | 'editor' | 'settings'; +type ViewType = 'landing' | 'home' | 'editor' | 'settings' | 'pricing' | 'compare' | 'shared' | 'privacy' | 'terms'; + +function parsePath(pathname: string): { view: ViewType; sharedSlug: string | null } { + if (pathname === '/home') return { view: 'home', sharedSlug: null }; + if (pathname === '/editor') return { view: 'editor', sharedSlug: null }; + if (pathname === '/settings') return { view: 'settings', sharedSlug: null }; + if (pathname === '/pricing') return { view: 'pricing', sharedSlug: null }; + if (pathname === '/compare') return { view: 'compare', sharedSlug: null }; + if (pathname === '/privacy') return { view: 'privacy', sharedSlug: null }; + if (pathname === '/terms') return { view: 'terms', sharedSlug: null }; + const shareMatch = pathname.match(/^\/s\/([A-Za-z0-9_-]{6,64})\/?$/); + if (shareMatch) return { view: 'shared', sharedSlug: shareMatch[1] }; + return { view: 'landing', sharedSlug: null }; // default for '/' and unknown paths +} + +/** Why AI generation is unavailable, or null when it's usable. */ +type AiGate = null | 'hosted-signin' | 'hosted-upgrade' | 'byok-nokey'; export default function App() { // --- View State --- - const [view, setView] = useState(() => { - // Initialize view based on URL path - const path = window.location.pathname; - if (path === '/home') return 'home'; - if (path === '/editor') return 'editor'; - if (path === '/settings') return 'settings'; - return 'landing'; // default to landing for '/' and any other path - }); + const [view, setView] = useState(() => parsePath(window.location.pathname).view); + const [sharedSlug, setSharedSlug] = useState(() => parsePath(window.location.pathname).sharedSlug); // --- Data State --- const [graphs, setGraphs] = useState([]); @@ -127,6 +160,8 @@ export default function App() { }>({ visible: false, currentColor: '#3b82f6', onSelect: () => { } }); const [exportModalOpen, setExportModalOpen] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); + const [cloudHistoryOpen, setCloudHistoryOpen] = useState(false); // History for undo/redo const [history, setHistory] = useState([]); @@ -139,23 +174,119 @@ export default function App() { const { showTooltip: showSendTooltip, hideTooltip: hideSendTooltip, TooltipPortal: SendTooltipPortal } = usePortalTooltip({ delay: 400, placement: 'top' }); - // --- Load from localStorage on mount --- + // --- Cloud (accounts + sync are Supporter features; app is fully usable without) --- + const { configured: cloudConfigured, loading: authLoading, user, isPro } = useAuth(); + + // Live refs so applyRemote (a stable, dep-free callback) can see the graph + // currently open in the editor without being re-created on every edit. + const activeGraphIdRef = useRef(null); + const currentDiagramRef = useRef(INITIAL_DIAGRAM); + + // Which account's local data is live. `null` while the session is still being + // restored, so we don't briefly load guest data for someone who is signed in. + const storeScope = authLoading ? null : (user?.id ?? GUEST_SCOPE); + const [loadedScope, setLoadedScope] = useState(null); + // Browser storage refused to give up this namespace. Saving is off for the + // session so the stored copy survives, and the banner says so. + const [storageUnreadable, setStorageUnreadable] = useState(false); + const loadedScopeRef = useRef(null); + // True between signing in and deciding whether signed-out work joins this + // account. The editor holds off creating a blank diagram until it resolves. + const [pendingGuestAdoption, setPendingGuestAdoption] = useState(false); + // The handover is decided (so `pendingGuestAdoption` is already false) but the + // copy is still running. See the auto-open effect. + const [adoptingGuestWork, setAdoptingGuestWork] = useState(false); + // A finished handover waiting for its namespace to be the live one. By the + // time adoptScope resolves it has already emptied the guest namespace, so + // this data exists in exactly one place and dropping it loses the user's + // work. Holding it here lets the effect below wait for the right moment + // instead of deciding, from inside an async callback, whether that moment has + // passed. + const [pendingAdopted, setPendingAdopted] = useState<{ scope: string; graphs: Graph[]; projects: Project[] } | null>(null); + + const applyRemote = useCallback((remoteGraphs: Graph[], remoteProjects: Project[], forUserId: string) => { + // A sync that lands after the account changed is carrying the previous + // account's cloud data. Dropping it keeps that data out of this account + // (and off this account's next upload). + if (loadedScopeRef.current !== forUserId) return; + setGraphs(remoteGraphs); + setProjects(remoteProjects); + // If the graph open in the editor was changed by this pull (e.g. edited on + // another device), refresh the editor's live copy, otherwise the next + // autosave writes our stale currentDiagram back over the newer cloud version. + // BUT only when there are no unsaved local edits in flight: a pending + // autosave means currentDiagram holds edits not yet written to `graphs`, and + // overwriting it here would silently discard them and reset the undo stack. + const openId = activeGraphIdRef.current; + if (openId && autosaveDebounceRef.current === null) { + const incoming = remoteGraphs.find((g) => g.id === openId); + if (!incoming) { + // Deleted on another device. The merge already dropped it, so leaving + // it open would keep editing (and re-uploading) a graph that no longer + // exists. Close it and let the auto-open effect pick the next one. + setActiveGraphId(null); + const blank = { ...EMPTY_DIAGRAM }; + setCurrentDiagram(blank); + setHistory([blank]); + historyRef.current = [blank]; + historyIndexRef.current = 0; + setHistoryIndex(0); + } else if (JSON.stringify(incoming.diagramData) !== JSON.stringify(currentDiagramRef.current)) { + setCurrentDiagram(incoming.diagramData); + setHistory([incoming.diagramData]); + historyRef.current = [incoming.diagramData]; + historyIndexRef.current = 0; + setHistoryIndex(0); + } + } + }, []); + + const { syncState, syncNow } = useCloudSync({ + // Withhold the account until its own local data is the data in memory. + // Syncing during a switch, while the previous account's diagrams are still + // loaded, would upload them into this account. + userId: user && isPro && loadedScope === user.id ? user.id : null, + hasInitialized, + graphs, + projects, + applyRemote, + }); + + // A signed-in Supporter's local store can be empty simply because the first + // cloud pull hasn't landed yet, used below to avoid creating (and syncing + // up) a throwaway blank graph before we've heard whether the cloud has data. + // 'disabled' counts too: for a Supporter it means the sync loop hasn't picked + // this account up yet, which is still "before the first pull". Without it + // there's a render where the store looks empty and the editor would create a + // blank diagram (and upload it) moments before the real data arrives. + const awaitingFirstPull = + cloudConfigured && !!user && isPro && + syncState.lastSyncedAt === null && + (syncState.status === 'idle' || syncState.status === 'syncing' || syncState.status === 'disabled'); + + // The first pull did not just fail to arrive, it failed outright. We cannot + // tell whether this account's cloud is empty, so any decision that depends on + // "the account has nothing" has to stay unresolved. + const firstPullFailed = + cloudConfigured && !!user && isPro && + syncState.lastSyncedAt === null && + (syncState.status === 'error' || syncState.status === 'offline'); + + // --- Load shared editor preferences on mount --- + // Diagrams and projects are NOT loaded here: they belong to whichever account + // is signed in, which isn't known until the session has been restored. See + // the scope effect below. useEffect(() => { + // Open the diagram store (and migrate into it) early. Reads wait on this + // internally, so this is just a head start, not a prerequisite. + void initLocalStore(); + // Ask the browser not to evict saved diagrams when disk runs low. + void requestPersistentStorage(); try { - const savedGraphs = localStorage.getItem(STORAGE_KEYS.graphs); - const savedProjects = localStorage.getItem(STORAGE_KEYS.projects); const savedSettings = localStorage.getItem(STORAGE_KEYS.settings); const savedSpecial = localStorage.getItem(STORAGE_KEYS.specialColors); const savedStandard = localStorage.getItem(STORAGE_KEYS.standardColors); - if (savedGraphs) { - const parsed = JSON.parse(savedGraphs) as Graph[]; - setGraphs(parsed); - } - if (savedProjects) { - const parsed = JSON.parse(savedProjects) as Project[]; - setProjects(parsed); - } if (savedSettings) { const parsed = JSON.parse(savedSettings); setSettings(s => ({ ...s, ...parsed })); @@ -173,11 +304,158 @@ export default function App() { } } } catch (e) { - console.error('Failed to load data from localStorage:', e); + console.error('Failed to load preferences from localStorage:', e); } - setHasInitialized(true); }, []); + // --- Per-account local data --- + // Everyone who uses this browser gets their own namespace: one per signed-in + // account, plus a shared "guest" one for work done signed out. Switching + // accounts swaps which namespace is live, and never deletes the other one. + useEffect(() => { + if (storeScope === null || storeScope === loadedScope) return; + // This effect is about to read the incoming namespace off disk, and a + // finished handover was written to disk before it ever got here, so that + // read already includes it. Dropping the held copy avoids replaying it over + // whatever the read (or a sync that ran meanwhile) turned up. Note this sits + // after the early return above: coming back to a namespace that never + // stopped being the loaded one does no read, and must not discard anything. + setPendingAdopted(null); + let cancelled = false; + void (async () => { + const stored = await readScope(storeScope); + // Signing in with nothing of your own, over work done signed out, is the + // one case where the two might be joined. Resolve it here so the editor + // waits for that decision instead of creating a blank diagram meanwhile. + const guestPending = + stored.ok + && storeScope !== GUEST_SCOPE + && stored.graphs.length === 0 + && stored.projects.length === 0 + && await scopeHasContent(GUEST_SCOPE); + // The account may have changed again while this was loading; whichever + // effect run matches the live namespace is the one allowed to apply. + if (cancelled) return; + + setGraphs(stored.graphs); + setProjects(stored.projects); + setPendingGuestAdoption(guestPending); + // Drop timers armed by the outgoing account. A pending autosave would + // write its diagram into this namespace, and a pending history push would + // put it in the new account's undo stack. + if (historyDebounceRef.current !== null) { + window.clearTimeout(historyDebounceRef.current); + historyDebounceRef.current = null; + } + if (autosaveDebounceRef.current !== null) { + window.clearTimeout(autosaveDebounceRef.current); + autosaveDebounceRef.current = null; + } + // Close whatever was open and blank the canvas: it belongs to the + // namespace we're leaving. The auto-open effect below picks this + // account's most recent diagram once its data is in place. + setActiveGraphId(null); + const blank = { ...EMPTY_DIAGRAM }; + setCurrentDiagram(blank); + setHistory([blank]); + historyRef.current = [blank]; + // undo/redo read the ref, not the state. Leaving it stale lets Ctrl+Z + // index past the end of the new one-item history and feed undefined into + // the canvas. + historyIndexRef.current = 0; + setHistoryIndex(0); + // `loadedScope` records which namespace this effect has settled, so it is + // set either way: leaving it behind on a failure would re-run the effect + // forever, and leaving it pointing at the *previous* account would let + // that account's library be overwritten with this one's empty arrays the + // moment the user switched back. Whether saving is allowed is a separate + // question, and `storageUnreadable` is what answers it. + setLoadedScope(storeScope); + setStorageUnreadable(!stored.ok); + if (!stored.ok) { + console.error(`Could not read local storage for ${storeScope}; saving is off so it is not overwritten.`); + } + setHasInitialized(true); + })(); + return () => { cancelled = true; }; + }, [storeScope, loadedScope]); + + // --- Hand guest work to the account that signs in --- + // Work done signed out should follow you into your account, but only when + // doing so can't mix it into diagrams that are already there. So we adopt it + // only if this account has nothing of its own, and for Supporters only once + // the first cloud pull has told us whether the account is really empty. + // Otherwise the guest namespace is left untouched, and signing out returns to + // it intact. + useEffect(() => { + if (storeScope === null) return; + const decision = decideGuestAdoption({ + pending: pendingGuestAdoption, + scopeReady: loadedScope === storeScope, + awaitingFirstPull, + firstPullFailed, + accountHasContent: graphs.length > 0 || projects.length > 0, + }); + if (decision === 'wait') return; + + // Settle the decision before awaiting anything, so this can't run twice and + // hand the same work over twice. + setPendingGuestAdoption(false); + // 'keep-separate': the account brought its own diagrams (pulled from the + // cloud), so the signed-out work stays where it is, ready for next time. + if (decision !== 'adopt') return; + + // `pendingGuestAdoption` is already false by now, so on its own it no longer + // holds the auto-open effect back. Without this second flag that effect sees + // an empty library, creates a blank diagram and syncs it up, and the adopted + // graphs then replace it locally while the stray row stays in the cloud. + setAdoptingGuestWork(true); + // Deliberately no effect-scoped `cancelled` flag. Setting + // `pendingGuestAdoption` above re-runs this effect, which would trip such a + // flag immediately, and the copy is not conditional on this effect still + // being current: it moves the diagrams on disk either way. + const adoptingInto = storeScope; + void (async () => { + try { + const adopted = await adoptScope(GUEST_SCOPE, adoptingInto); + // null means the copy failed and the work is still in the guest + // namespace. Leave this account empty rather than showing diagrams that + // were not actually saved to it. + if (!adopted) return; + // Recorded, not published. Deciding here whether the namespace is still + // live means reading state this closure captured before the await, and + // every version of that check has been wrong in a way that ends with + // the adopted diagrams being overwritten by a blank one. + setPendingAdopted({ scope: adoptingInto, graphs: adopted.graphs, projects: adopted.projects }); + } finally { + setAdoptingGuestWork(false); + } + })(); + }, [pendingGuestAdoption, storeScope, loadedScope, awaitingFirstPull, firstPullFailed, graphs.length, projects.length]); + + // Publish an adopted library once its namespace is the live one. Running as + // an effect is the point: it reads `storeScope` and `loadedScope` as they are + // now, not as they were when the copy started, and it waits rather than + // discarding. An account switch mid-copy therefore parks the diagrams here + // until that account is back, and if the switch away completed, the load + // effect clears this because its own read of the disk already has them. + useEffect(() => { + if (!pendingAdopted) return; + if (storeScope !== pendingAdopted.scope || loadedScope !== pendingAdopted.scope) return; + setGraphs(pendingAdopted.graphs); + setProjects(pendingAdopted.projects); + setPendingAdopted(null); + }, [pendingAdopted, storeScope, loadedScope]); + + // Keep live refs in sync for dep-free callbacks (see applyRemote). + // Synced in effects rather than assigned during render: React may discard a + // render pass, and a ref written in the body would keep the value from that + // abandoned pass. Every reader below runs in an async callback after commit, + // so a one-commit lag is not observable. + useEffect(() => { activeGraphIdRef.current = activeGraphId; }, [activeGraphId]); + useEffect(() => { currentDiagramRef.current = currentDiagram; }, [currentDiagram]); + useEffect(() => { loadedScopeRef.current = loadedScope; }, [loadedScope]); + // --- Auto-open most recent graph logic --- useEffect(() => { // Only run when navigating to editor without an active graph, after initialization @@ -195,10 +473,29 @@ export default function App() { historyRef.current = [mostRecent.diagramData]; setHistoryIndex(0); } else if (graphs.length === 0) { + // Wait for the first cloud pull before assuming a Supporter has no graphs + //, otherwise we'd create a blank one and sync it up as clutter. + if (awaitingFirstPull) return; + // Likewise, don't create one while work done signed out is about to be + // handed to this account: that would leave a stray blank diagram beside it + // (and select it, since the graph below is chosen unconditionally). + // A failed pull leaves that decision unresolved indefinitely, so don't + // hold the editor hostage to it. + if (pendingGuestAdoption && !firstPullFailed) return; + // The copy itself is a bounded local operation, so once it is actually + // running, always wait for it: `firstPullFailed` says nothing about + // whether the diagrams are about to arrive. + if (adoptingGuestWork) return; + // Copy finished, waiting to be published into this namespace. Creating a + // blank graph in the gap would be saved over it. Scoped to this namespace + // on purpose: a handover parked for another account must not stop this + // one getting its starting diagram. + if (pendingAdopted && pendingAdopted.scope === storeScope) return; // Create new graph if none exist const newGraph: Graph = { id: generateId(), title: EMPTY_DIAGRAM.title, + titleSetByUser: false, caption: EMPTY_DIAGRAM.caption || 'Figure 1: Economic Diagram', messages: [], diagramData: { ...EMPTY_DIAGRAM }, @@ -217,26 +514,21 @@ export default function App() { historyRef.current = [newGraph.diagramData]; setHistoryIndex(0); } - }, [view, hasInitialized, activeGraphId, graphs.length]); // Use graphs.length instead of graphs to avoid re-trigger on content changes + }, [view, hasInitialized, activeGraphId, graphs.length, awaitingFirstPull, pendingGuestAdoption, adoptingGuestWork, pendingAdopted, storeScope, firstPullFailed]); // Use graphs.length instead of graphs to avoid re-trigger on content changes // --- Save to localStorage when data changes (only after initial load) --- + // Only write once the namespace in memory is the one we last loaded. During an + // account switch those differ for a render, and writing then would save the + // outgoing account's diagrams over the incoming account's. useEffect(() => { - if (!hasInitialized) return; - try { - localStorage.setItem(STORAGE_KEYS.graphs, JSON.stringify(graphs)); - } catch (e) { - console.error('Failed to save graphs:', e); - } - }, [graphs, hasInitialized]); + if (!hasInitialized || storageUnreadable || loadedScope === null || loadedScope !== storeScope) return; + void writeGraphs(loadedScope, graphs); + }, [graphs, hasInitialized, storageUnreadable, loadedScope, storeScope]); useEffect(() => { - if (!hasInitialized) return; - try { - localStorage.setItem(STORAGE_KEYS.projects, JSON.stringify(projects)); - } catch (e) { - console.error('Failed to save projects:', e); - } - }, [projects, hasInitialized]); + if (!hasInitialized || storageUnreadable || loadedScope === null || loadedScope !== storeScope) return; + void writeProjects(loadedScope, projects); + }, [projects, hasInitialized, storageUnreadable, loadedScope, storeScope]); useEffect(() => { if (!hasInitialized) return; @@ -282,21 +574,66 @@ export default function App() { // Listen for browser back/forward navigation useEffect(() => { const handlePopState = () => { - const path = window.location.pathname; - if (path === '/home') setView('home'); - else if (path === '/editor') setView('editor'); - else if (path === '/settings') setView('settings'); - else setView('landing'); + const parsed = parsePath(window.location.pathname); + setView(parsed.view); + setSharedSlug(parsed.sharedSlug); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); - // Scroll to bottom of chat + // Keep the document title and canonical URL in sync with the SPA route so + // content routes (/pricing, /compare) self-canonicalize instead of being + // seen as duplicates of the homepage's hardcoded canonical. + const countedFirstViewRef = useRef(false); + useEffect(() => { + const SITE = 'https://ib-econgraph-ai.vercel.app'; + const meta: Record = { + landing: { title: 'IB EconGraph AI: Free AI-Powered Economics Diagram Editor', path: '/' }, + pricing: { title: 'Pricing · Free Forever · IB EconGraph AI', path: '/pricing' }, + compare: { title: 'How IB EconGraph AI Compares: IB Economics Diagram Tools', path: '/compare' }, + privacy: { title: 'Privacy Policy · IB EconGraph AI', path: '/privacy' }, + terms: { title: 'Terms of Service · IB EconGraph AI', path: '/terms' }, + }; + // App-only views (home/editor/settings/shared) canonicalize to the homepage. + const entry = meta[view] ?? { title: 'IB EconGraph AI: Free Economics Diagram Editor', path: '/' }; + document.title = entry.title; + let link = document.querySelector('link[rel="canonical"]'); + if (!link) { + link = document.createElement('link'); + link.rel = 'canonical'; + document.head.appendChild(link); + } + link.href = SITE + entry.path; + + // GoatCounter counts the first load itself (the tag in index.html), so only + // report navigations after that, otherwise every visit double-counts its + // entry page. Views with no metadata entry are app UI rather than content; + // report the real path for those, minus any share slug, which is a + // capability token and must not reach an analytics endpoint. + if (!countedFirstViewRef.current) { + countedFirstViewRef.current = true; + return; + } + const countedPath = meta[view] + ? entry.path + : window.location.pathname.replace(/^\/s\/[^/]+\/?$/, '/s/'); + // Optional chaining throughout: the tag is `async`, so on a fast navigation + // it may not have loaded yet. A missed count is fine; a crash is not. + const gc = (window as unknown as { + goatcounter?: { count?: (opts: { path: string; title: string }) => void }; + }).goatcounter; + gc?.count?.({ path: countedPath, title: entry.title }); + }, [view]); + + // Scroll the chat to the bottom when a message is added to the open graph (or + // when switching graphs), not on every diagram edit, which also mutates + // `graphs` but leaves the message list unchanged. + const activeMessageCount = graphs.find(g => g.id === activeGraphId)?.messages.length ?? 0; useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [graphs, activeGraphId]); + }, [activeMessageCount, activeGraphId]); // Cleanup on unmount useEffect(() => () => { @@ -326,13 +663,34 @@ export default function App() { historyDebounceRef.current = window.setTimeout(() => pushToHistory(data), 250); }, [pushToHistory]); - const scheduleAutosave = useCallback((data: DiagramData) => { + /** + * `fromCanvasEdit` distinguishes a direct edit on the canvas from the other + * callers (undo/redo, clear, restoring an older version or a chat message). + * Only a direct edit can mean the user retitled the graph; the rest replay a + * title that was already chosen for them, and treating those as a rename + * would freeze the title against future AI generations. + */ + const scheduleAutosave = useCallback((data: DiagramData, fromCanvasEdit = false) => { if (!activeGraphId) return; if (autosaveDebounceRef.current) window.clearTimeout(autosaveDebounceRef.current); autosaveDebounceRef.current = window.setTimeout(() => { + // Clear the handle first: applyRemote reads this ref to mean "unsaved + // edits are in flight, don't overwrite the canvas". Left set, it stays + // true forever after the first autosave and cross-device pulls silently + // stop refreshing the open diagram. + autosaveDebounceRef.current = null; setGraphs(prev => prev.map(g => g.id === activeGraphId - ? { ...g, diagramData: data, title: data.title, lastModified: Date.now() } + ? { + ...g, + diagramData: data, + title: data.title, + // Editing the title directly on the canvas counts as naming it. + // Only a real change flips this; ordinary canvas edits carry the + // existing title through unchanged. + titleSetByUser: g.titleSetByUser || (fromCanvasEdit && data.title !== g.title), + lastModified: Date.now(), + } : g )); }, 200); @@ -341,7 +699,7 @@ export default function App() { const handleDataChange = useCallback((newData: DiagramData) => { setCurrentDiagram(newData); scheduleHistoryPush(newData); - scheduleAutosave(newData); + scheduleAutosave(newData, true); }, [scheduleHistoryPush, scheduleAutosave]); const undo = useCallback(() => { @@ -412,6 +770,7 @@ export default function App() { const newGraph: Graph = { id: generateId(), title: EMPTY_DIAGRAM.title, + titleSetByUser: false, caption: EMPTY_DIAGRAM.caption || 'Figure 1: Economic Diagram', projectId, messages: [], @@ -444,6 +803,7 @@ export default function App() { danger: true, onConfirm: () => { setGraphs(prev => prev.filter(g => g.id !== graphId)); + recordTombstones('graphs', [graphId]); if (activeGraphId === graphId) { setActiveGraphId(null); navigateToView('home'); @@ -457,6 +817,7 @@ export default function App() { // (Used for bulk delete where HomePage shows the confirmation) const deleteGraphsDirect = useCallback((graphIds: string[]) => { setGraphs(prev => prev.filter(g => !graphIds.includes(g.id))); + recordTombstones('graphs', graphIds); // If active graph is being deleted, go to home if (activeGraphId && graphIds.includes(activeGraphId)) { setActiveGraphId(null); @@ -497,9 +858,10 @@ export default function App() { danger: true, onConfirm: () => { setProjects(prev => prev.filter(p => p.id !== projectId)); + recordTombstones('projects', [projectId]); // Unassign graphs from this project setGraphs(prev => prev.map(g => - g.projectId === projectId ? { ...g, projectId: undefined } : g + g.projectId === projectId ? { ...g, projectId: undefined, lastModified: Date.now() } : g )); setConfirmModal(c => ({ ...c, visible: false })); } @@ -541,6 +903,7 @@ export default function App() { g.id === graphId ? { ...g, title: newName.trim(), + titleSetByUser: true, diagramData: { ...g.diagramData, title: newName.trim() }, lastModified: Date.now() } : g @@ -558,9 +921,58 @@ export default function App() { )); }, []); - const handleImportData = useCallback((data: { graphs: Graph[]; projects: Project[]; specialColors?: string[]; standardColors?: string[] }) => { - setGraphs(data.graphs); - setProjects(data.projects); + const handleImportData = useCallback(async (data: { graphs: Graph[]; projects: Project[]; specialColors?: string[]; standardColors?: string[] }) => { + // Import replaces everything, tombstone current items missing from the + // backup so cloud sync propagates the replacement instead of undoing it. + // Bind the restore to the namespace it started in. Signing in or out during + // the cloud read below would otherwise drop this backup, and the deletions + // that come with it, into whichever account happens to be live by then. + const startedIn = loadedScopeRef.current; + + const importedGraphIds = new Set(data.graphs.map(g => g.id)); + const importedProjectIds = new Set(data.projects.map(p => p.id)); + const graphTombstones = graphs.filter(g => !importedGraphIds.has(g.id)).map(g => g.id); + const projectTombstones = projects.filter(p => !importedProjectIds.has(p.id)).map(p => p.id); + + // Cloud rows that live only on another device were never in local `graphs`, + // so the filter above can't tombstone them, without this, the next sync + // pulls them back and the "replace everything" restore silently resurrects + // diagrams the backup was meant to drop. Best-effort: null when offline. + // + // This has to finish BEFORE the imported state is published: publishing + // schedules a sync, and a sync that runs while these tombstones are still + // missing merges the remote-only rows straight back in. + const cloud = await fetchCloudIds(); + + // Nothing above this point has written anything, so abandoning here leaves + // no trace in either account. + if (loadedScopeRef.current !== startedIn) { + setConfirmModal({ + visible: true, + title: 'Restore cancelled', + message: 'The account changed while the backup was being restored, so nothing was imported. Please try again.', + confirmText: 'OK', + danger: false, + onConfirm: () => setConfirmModal(c => ({ ...c, visible: false })), + }); + return; + } + + if (cloud) { + graphTombstones.push(...cloud.graphIds.filter(id => !importedGraphIds.has(id))); + projectTombstones.push(...cloud.projectIds.filter(id => !importedProjectIds.has(id))); + } + recordTombstones('graphs', graphTombstones); + recordTombstones('projects', projectTombstones); + + // Restored items must win last-write-wins against any wiped/tombstoned + // remote rows, and must not collide with a stale tombstone of the same id. + // Stamped after the await so they are newer than every tombstone above. + const now = Date.now(); + clearTombstones('graphs', data.graphs.map(g => g.id)); + clearTombstones('projects', data.projects.map(p => p.id)); + setGraphs(data.graphs.map(g => ({ ...g, lastModified: now }))); + setProjects(data.projects.map(p => ({ ...p, lastModified: now }))); if (data.specialColors && Array.isArray(data.specialColors) && data.specialColors.length >= 2) { setSpecialColors(data.specialColors); } @@ -569,7 +981,7 @@ export default function App() { } // Reset active graph since the data has changed setActiveGraphId(null); - }, []); + }, [graphs, projects]); const startFromHome = useCallback((projectId?: string) => { const graphId = createGraph(projectId); @@ -586,6 +998,22 @@ export default function App() { [graphs, activeGraphId] ); + /** + * A share stores a snapshot of `graph.diagramData`, but that field trails the + * canvas by the autosave debounce. Creating a link straight after an edit + * would publish the pre-edit diagram, so hand the share the live canvas. + */ + const shareGraph = useMemo( + () => (activeGraph ? { ...activeGraph, diagramData: currentDiagram, title: currentDiagram.title } : null), + [activeGraph, currentDiagram] + ); + + // Latest graphs, readable from async callbacks that would otherwise close + // over the snapshot taken before an `await` (e.g. a rename the user makes + // while a generation is still in flight). + const graphsRef = useRef(graphs); + useEffect(() => { graphsRef.current = graphs; }, [graphs]); + const projectGraphs = useMemo(() => { if (!activeGraph) return []; if (activeGraph.projectId) { @@ -602,17 +1030,38 @@ export default function App() { [activeGraph, projects] ); + // Single source of truth for AI-availability gating, shared by the chat + // submit guard and the editor warning banner so the two can't drift. Reads + // getAIProvider()/hasApiKey() fresh each call to reflect the latest settings. + const computeAiGate = useCallback((): AiGate => { + if (getAIProvider() === 'hosted') { + if (!user) return 'hosted-signin'; + if (!isPro) return 'hosted-upgrade'; + return null; + } + return hasApiKey() ? null : 'byok-nokey'; + }, [user, isPro]); + const handleSubmit = useCallback(async (e?: React.FormEvent, customPrompt?: string) => { if (e) e.preventDefault(); const promptText = customPrompt || prompt; if (!promptText.trim() || !activeGraphId) return; - // Check for API key before sending - if (!hasApiKey()) { + // Check the AI provider is usable before sending + const gate = computeAiGate(); + const aiBlockedMessage = + gate === 'hosted-signin' + ? 'Sign in (Settings > Account) to use hosted AI, or switch to a free provider with your own API key.' + : gate === 'hosted-upgrade' + ? 'Hosted AI is part of the Supporter plan. Upgrade on the Pricing page, or use your own free API key in Settings.' + : gate === 'byok-nokey' + ? 'API key not configured. Please add your API key in Settings before using AI features.' + : null; + if (aiBlockedMessage) { const errorMsg: Message = { id: generateId(), role: 'model', - content: "API key not configured. Please add your API key in Settings before using AI features.", + content: aiBlockedMessage, timestamp: Date.now() }; setGraphs(prev => prev.map(g => { @@ -649,11 +1098,27 @@ export default function App() { const history = activeGraph?.messages.map(m => `${m.role}: ${m.content}`) || []; const result = await generateDiagramData(promptText, history); + // Once the user has named the graph, that name is theirs and a later + // generation must not silently overwrite it. This keys off an explicit + // flag rather than the title itself: inferring it from "title differs + // from the default" locked the graph to whatever the *first* generation + // called it, because that generation writes its title back below. + // Read the graph as it is *now*, not as it was when the request was sent, + // so a rename made while this was generating still wins. + const liveGraph = graphsRef.current.find(g => g.id === activeGraphId) || null; + const userNamed = !!liveGraph && ( + liveGraph.titleSetByUser + // Graphs saved before the flag existed: fall back to the old heuristic + // so an existing hand-picked title is never overwritten. + ?? (liveGraph.title.trim() !== '' && liveGraph.title !== EMPTY_DIAGRAM.title) + ); + const nextDiagram = userNamed ? { ...result, title: liveGraph!.title } : result; + const aiMsg: Message = { id: generateId(), role: 'model', content: `Here is the diagram for "${promptText}". You can drag points to adjust curves or double click labels to edit them.`, - diagramData: result, + diagramData: nextDiagram, timestamp: Date.now() }; @@ -662,15 +1127,15 @@ export default function App() { return { ...g, messages: [...g.messages, aiMsg], - diagramData: result, - title: result.title, + diagramData: nextDiagram, + title: nextDiagram.title, lastModified: Date.now() }; } return g; })); - setCurrentDiagram(result); - pushToHistory(result); + setCurrentDiagram(nextDiagram); + pushToHistory(nextDiagram); } catch (err) { const message = err instanceof Error @@ -691,7 +1156,7 @@ export default function App() { } finally { setIsLoading(false); } - }, [activeGraphId, activeGraph, prompt, pushToHistory, setGraphs]); + }, [activeGraphId, activeGraph, prompt, pushToHistory, setGraphs, computeAiGate]); const handleNewChat = useCallback(() => { if (!activeGraphId) return; @@ -815,6 +1280,14 @@ export default function App() { newData.annotatedPoints = [...newData.annotatedPoints, ...newPoints]; } + if (template.data.textLabels && template.data.textLabels.length > 0) { + const newLabels = template.data.textLabels.map(l => ({ + ...l, + id: `label-${generateId()}` + })); + newData.textLabels = [...(newData.textLabels ?? []), ...newLabels]; + } + handleDataChange(newData); }; @@ -883,11 +1356,76 @@ export default function App() { "Perfect Competition Long Run" ]; + // Provider-aware AI availability (drives the editor warning banner). Uses the + // same computeAiGate() discriminant as the chat submit guard above. + const aiGate = computeAiGate(); + const aiWarning: { title: string; body: React.ReactNode } | null = + aiGate === 'hosted-signin' + ? { + title: 'Sign in to use hosted AI', + body: <>Hosted AI needs an account. Sign in from{' '} + + {' '}or switch to a free provider with your own key. + } + : aiGate === 'hosted-upgrade' + ? { + title: 'Hosted AI is a Supporter feature', + body: <>See the{' '} + + , or keep generating free with your own key in{' '} + . + } + : aiGate === 'byok-nokey' + ? { + title: 'API key not configured', + body: <>Add your API key in{' '} + + {' '}to use AI features. + } + : null; + // --- Render Views --- + if (view === 'shared' && sharedSlug) { + return ( + navigateToView('landing')} + /> + ); + } + + if (view === 'pricing') { + return ( + navigateToView('home')} + onOpenLanding={() => navigateToView('landing')} + onOpenCompare={() => navigateToView('compare')} + onOpenSettings={() => navigateToView('settings')} + /> + ); + } + + if (view === 'compare') { + return ( + navigateToView('home')} + onOpenLanding={() => navigateToView('landing')} + onOpenPricing={() => navigateToView('pricing')} + /> + ); + } + + if (view === 'privacy') return ; + if (view === 'terms') return ; + if (view === 'landing') { return ( navigateToView('home')} + onOpenPricing={() => navigateToView('pricing')} + onOpenCompare={() => navigateToView('compare')} + onOpenPrivacy={() => navigateToView('privacy')} + onOpenTerms={() => navigateToView('terms')} /> ); } @@ -938,6 +1476,9 @@ export default function App() { graphs={graphs} projects={projects} onImportData={handleImportData} + syncState={syncState} + onSyncNow={syncNow} + onOpenPricing={() => navigateToView('pricing')} /> ); } @@ -945,6 +1486,15 @@ export default function App() { // Editor View return ( <> + {storageUnreadable && ( +
+ Your saved diagrams could not be read from this browser. Saving is turned off so nothing is overwritten. Reload the page to try again. +
+ )} + {/* Modals */} + setShareModalOpen(false)} + graph={shareGraph} + onOpenSettings={() => navigateToView('settings')} + onOpenPricing={() => navigateToView('pricing')} + /> + setCloudHistoryOpen(false)} + graph={activeGraph} + onRestore={(diagramData) => { + setCurrentDiagram(diagramData); + pushToHistory(diagramData); + scheduleAutosave(diagramData); + }} + />
@@ -1091,6 +1658,25 @@ export default function App() {
+ {cloudConfigured && ( + <> + + + + )} {' '} - to use AI features. -

+

{aiWarning.title}

+

{aiWarning.body}

)} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2011da1..96a2ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,73 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] - 2025-02-07 +## [1.1.0] - 2026-07-27 + +### Added + +- **Supporter plan** ($5/mo or $50/yr via Polar, merchant of record) with a + public free-forever guarantee: everything a student needs for their IA stays + free, unlimited, and watermark-free +- Accounts (email + password with one-time verification, or Google) via Supabase, + optional and only needed for cloud features +- **Hosted AI** provider: server-side Gemini generation with no API key setup, + metered at 150 generations/month per Supporter (BYOK stays unlimited & free). + Three interchangeable backends, first configured wins: Vertex AI express key, + Vertex AI with a project (ADC locally, service account on Vercel), or a + Google AI Studio key +- **Account deletion** (`/api/delete-account`): permanently removes the account + and all cloud data, cancelling any active subscription first so a deleted + account can never keep being billed +- **Privacy Policy** (`/privacy`) and **Terms of Service** (`/terms`) pages, + governed by Finnish law and preserving EU/EEA consumer rights +- **Database keepalive workflow** (`.github/workflows/db-keepalive.yml`): a cheap + read every ~5 days so a free-tier Supabase project never pauses after 7 days + of inactivity +- **Cloud sync** across devices: local-first, last-write-wins with deletion + tombstones, plus automatic version history (restorable from the editor) +- **Shareable view-only links** for graphs and projects (`/s/:slug`), revocable, + never including chat history +- **Custom template library**: save your own curve setups, synced to your account +- Pricing page (`/pricing`) and fact-checked comparison page (`/compare`) +- 12 prerendered SEO landing pages (`/diagrams/*`) with IB-specific content, + generated at build time along with the sitemap +- **Per-account local storage**: each account that signs in on a browser gets + its own local diagrams, alongside a shared one for work done signed out. + Switching accounts on a shared computer no longer erases anyone's work. + Signed-out work is handed to the account you sign into only when that account + has no diagrams of its own, so two people's diagrams are never merged. + Diagrams now live in IndexedDB (gigabytes) rather than localStorage (~5MB + shared with the auth token), migrated automatically on first load +- Supporter recognition: opt-in name listing in the README +- Backend setup guide (`docs/BACKEND_SETUP.md`): all cloud features degrade + gracefully when unconfigured, so forks stay zero-config + +### Changed + +- **Relicensed from MIT to AGPL-3.0.** Running a modified version as a network + service now requires publishing the modified source to its users. The project + name, logo, and branding are reserved separately and are not covered by the + code license, so forks should run under their own branding +- Source-code offer linked from Settings, as required by AGPL-3.0 section 13 +- Landing page: pricing/compare navigation, free-forever guarantee messaging, + support/sponsor links +- Settings: new Account & Cloud section (plan status, hosted AI usage meter, + sync controls, supporter preferences) +- Component templates now support text labels +- Renewal handling: entitlement is cushioned by a 1-day margin at the billing + boundary and is never moved backward by a delayed or out-of-order webhook, + while cancellation still ends access immediately +- Import/restore now asks for confirmation before overwriting existing data +- Em dashes and arrow glyphs removed from user-visible text throughout + +### Security + +- Version history is now capped in the database itself. `prune_graph_versions` + clamps its caller-supplied keep count, and an insert trigger enforces a hard + ceiling per graph, so a tampered client cannot grow `graph_versions` without + bound by requesting a huge count or skipping the prune call entirely + +## [1.0.0] - 2026-02-07 ### Added @@ -24,4 +90,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Box select and eraser tools - Pan and zoom controls +[1.1.0]: https://github.com/sukarth/IB-EconGraph-AI/releases/tag/v1.1.0 [1.0.0]: https://github.com/sukarth/IB-EconGraph-AI/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 88aacfd..c9d4a78 100644 --- a/README.md +++ b/README.md @@ -14,24 +14,46 @@ IB EconGraph AI is a web-based diagram editor designed specifically for the IB E ## Features -- **AI-Powered Generation** — Describe any economics concept in plain English and get an accurate, labeled diagram generated by Google Gemini AI. -- **Manual Drawing Tools** — Draw curves, lines, points, and shapes with precision. Drag entire curves or individual control points. -- **Component Library** — 15+ pre-built templates including Supply & Demand, Monopoly, Tax Incidence, Negative Externalities, and more. -- **Area Shading** — Shade consumer surplus, producer surplus, deadweight/welfare loss, tax revenue, and other regions. -- **Smart Snapping** — Snap to grid and existing points for pixel-perfect alignment. -- **Project Organization** — Organize diagrams into projects. Search, rename, and manage your work. -- **Export** — Export diagrams as high-quality SVG files or PNG and JPEG images. -- **Import/Export Data** — Back up and restore all your graphs, projects, and color palettes as JSON. -- **Customizable Colors** — Full color palette with custom color support. -- **Keyboard Shortcuts** — Undo/redo (Ctrl+Z/Y), tool switching (S, B, L, C, P, T, F, E, H), and more. +- **AI-Powered Generation:** Describe any economics concept in plain English and get an accurate, labeled diagram generated by Google Gemini AI. +- **Manual Drawing Tools:** Draw curves, lines, points, and shapes with precision. Drag entire curves or individual control points. +- **Component Library:** 15+ pre-built templates including Supply & Demand, Monopoly, Tax Incidence, Negative Externalities, and more. +- **Area Shading:** Shade consumer surplus, producer surplus, deadweight/welfare loss, tax revenue, and other regions. +- **Smart Snapping:** Snap to grid and existing points for pixel-perfect alignment. +- **Project Organization:** Organize diagrams into projects. Search, rename, and manage your work. +- **Export:** Export diagrams as high-quality SVG files or PNG and JPEG images. +- **Import/Export Data:** Back up and restore all your graphs, projects, and color palettes as JSON. +- **Customizable Colors:** Full color palette with custom color support. +- **Keyboard Shortcuts:** Undo/redo (Ctrl+Z/Y), tool switching (S, B, L, C, P, T, F, E, H), and more. +- **Cloud Sync** *(Supporter)*: Diagrams synced across devices with version history. +- **Shareable Links** *(Supporter)*: Send a view-only link of a diagram or project to a teacher or group partner. +- **Hosted AI** *(Supporter)*: AI generation with no API key setup, 150 generations/month included. +- **Custom Templates** *(Supporter)*: Save your own curve setups as reusable, synced templates. + +## Free forever: the guarantee + +> **Everything a student needs to finish their IA is free and unlimited, forever.** + +That means unlimited diagrams and projects, every drawing tool and template, all +export formats at full quality with **no watermark**, **unlimited AI generation +with your own free API key (BYOK)**, and local JSON backup/restore. None of this +will ever move behind a paywall. + +The optional **Supporter plan** ($5/month or $50/year) adds hosted conveniences +(hosted AI without API keys, cloud sync with version history, share links, synced +custom templates) and keeps the project alive. See the +[pricing page](https://ib-econgraph-ai.vercel.app/pricing) and +[how we compare](https://ib-econgraph-ai.vercel.app/compare) to other tools. ## Tech Stack - **React 19** with TypeScript - **Vite** for build tooling - **Tailwind CSS** for styling -- **Google Gemini AI** (2.5 Flash) for diagram generation +- **Google Gemini AI** / **OpenRouter** (BYOK) for diagram generation - **Lucide React** for icons +- **Supabase** (auth + Postgres + RLS) for optional accounts & cloud sync +- **Polar** (merchant of record) for optional Supporter subscriptions +- **Vercel** serverless functions for hosted AI & billing endpoints ## Usage @@ -60,7 +82,13 @@ npm install ### Configuration -Enter your API key in the app's Settings page after launching. +Enter your API key in the app's Settings page after launching. No `.env` file is +needed for the core app. + +To self-host the optional cloud features (accounts, sync, Supporter billing, +hosted AI), see [docs/BACKEND_SETUP.md](docs/BACKEND_SETUP.md) and +[.env.example](.env.example). Everything degrades gracefully when unconfigured, +so a fork with no backend keys is simply the full free/local app. ### Development @@ -68,7 +96,7 @@ Enter your API key in the app's Settings page after launching. npm run dev ``` -Opens site at [http://localhost:4000](http://localhost:4000). +Opens the site at [http://localhost:4000](http://localhost:4000). ### Production Build @@ -82,24 +110,55 @@ To serve the production build locally. ## Project Structure ``` -├── App.tsx # Main application component +├── App.tsx # Main application component + routing ├── index.html # HTML entry point with SEO meta tags ├── index.tsx # React DOM initialization -├── types.ts # TypeScript type definitions -├── paletteTypes.ts # Color palette types -├── vite.config.ts # Vite configuration +├── types.ts / paletteTypes.ts # TypeScript type definitions +├── vite.config.ts # Vite config + dev-only API function shim +├── vercel.json # Routing, rewrites and function limits +├── .env.example # Every supported environment variable +├── .github/workflows/ +│ ├── db-keepalive.yml # Pings the DB so a free project never pauses +│ └── update-supporters.yml # Weekly README supporters refresh +├── api/ # Vercel serverless functions +│ ├── generate.ts # Hosted AI generation (metered, Supporter) +│ ├── usage.ts # Hosted AI usage meter +│ ├── checkout.ts / portal.ts # Polar billing +│ ├── delete-account.ts # Account + data deletion (cancels billing first) +│ ├── webhooks/polar.ts # Subscription state webhook +│ └── _lib/ # Server-only helpers (Supabase admin, Polar) +├── supabase/schema.sql # Database schema + RLS policies +├── docs/BACKEND_SETUP.md # Cloud/billing self-hosting guide +├── scripts/ +│ ├── generate-seo-pages.mjs # Build-time static SEO pages + sitemap +│ ├── seo-content.mjs # Per-diagram-type page content +│ └── update-supporters.mjs # README supporters list updater ├── components/ │ ├── LandingPage.tsx # Marketing/landing page +│ ├── PricingPage.tsx # Free-forever guarantee + Supporter plan +│ ├── ComparePage.tsx # Comparison vs other econ diagram tools +│ ├── LegalPages.tsx # Privacy Policy + Terms of Service │ ├── HomePage.tsx # Dashboard with graph management -│ ├── SettingsPage.tsx # API key and data management +│ ├── SettingsPage.tsx # API keys, account, sync, data management +│ ├── SharedViewPage.tsx # Public read-only share viewer (/s/:slug) │ ├── DiagramRenderer.tsx # SVG canvas and drawing engine -│ ├── ComponentLibrary.tsx # Pre-built diagram templates +│ ├── ComponentLibrary.tsx # Built-in + custom (synced) templates +│ ├── AccountSection.tsx # Account & Cloud settings card +│ ├── AuthModal.tsx / ShareModal.tsx / CloudHistoryModal.tsx │ ├── ToolbarLeft.tsx # Drawing tools panel │ ├── ToolbarRight.tsx # Utility controls (undo, zoom, export) -│ ├── Modal.tsx # Modal components (prompt, confirm, color picker, export) +│ ├── Modal.tsx # Modal components │ └── usePortalTooltip.tsx # Tooltip hook └── services/ - └── gemini.ts # Google Gemini AI integration + ├── ai.ts / aiProvider.ts # Provider facade (Gemini, OpenRouter, hosted) + ├── gemini.ts / openrouter.ts / hostedAi.ts + ├── diagramPrompt.ts # Shared AI prompt + schema (client & server) + ├── auth.tsx # Auth context (Supabase) + ├── entitlement.ts # Single source of truth for the Pro rule + ├── sync.ts / useCloudSync.ts # Local-first cloud sync engine + ├── shares.ts / customTemplates.ts / billing.ts + ├── cloudErrors.ts / keyObfuscation.ts # Shared helpers + └── supabaseClient.ts ``` ## IB Economics Topics Covered @@ -116,25 +175,48 @@ Contributions are welcome. Please open an issue first to discuss what you'd like 4. Push to the branch (`git push origin feature/my-feature`) 5. Open a Pull Request +By contributing, you agree that your contributions are licensed under the project's AGPL-3.0 license. + ## License -Distributed under the MIT License. See [LICENSE](LICENSE) for details. +Copyright (c) 2025-2026 Sukarth Acharya. + +Distributed under the GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text. + +In plain terms: you are free to use, study, modify, and share this code. If you run a modified version as a network service (for example, a hosted copy other people can use), the AGPL requires you to make your modified source available to those users, under the same license. That keeps the project and its improvements open for everyone. + +### Name and branding + +The AGPL covers the *code*. It does not grant rights to the project's name, logo, or branding: **"IB EconGraph AI"**, the EconGraph name, and the project logo are reserved by the author and are not licensed for reuse. + +You are welcome to fork, self-host, and build on this project. If you publish or operate your own version, please run it under your own name and branding, and keep the attribution and source-code offer that the AGPL requires. Do not present a fork as the official IB EconGraph AI or imply it is endorsed by or affiliated with this project. ## Planned Features Below are some planned features for the future. Feel free to **contribute** or suggest additional features! - Support for more diagram types (e.g. Lorenz curves, IS-LM models) -- Collaborative editing and sharing +- Real-time collaborative editing - Mobile-friendly interface - More AI customization options (e.g. style, complexity) -- Integration with other AI providers (OpenRouter,OpenAI, Anthropic) -- Sign-in and cloud storage for projects - Dark mode +- Classroom plan for teachers (one license, whole class gets Supporter). If + there's demand, [open an issue](https://github.com/sukarth/IB-EconGraph-AI/issues) to register interest. + +## Supporters + +A huge thank-you to the Supporters keeping this project free for every student. +([Become one](https://ib-econgraph-ai.vercel.app/pricing); you can opt in to be listed here from Settings.) + + + +*Become the first. See the [Supporter plan](https://ib-econgraph-ai.vercel.app/pricing).* + + ## Support -If this project saves you time, consider supporting my work — it keeps these tools free, open source, and maintained: +If this project saves you time, consider supporting my work. It keeps these tools free, open source, and maintained: [![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-%E2%9D%A4-EA4AAA?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Sukarth) [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-FF5E5B?logo=kofi&logoColor=white)](https://ko-fi.com/sukarth) diff --git a/api/_lib/polar.ts b/api/_lib/polar.ts new file mode 100644 index 0000000..6320296 --- /dev/null +++ b/api/_lib/polar.ts @@ -0,0 +1,90 @@ +import { Polar } from '@polar-sh/sdk'; + +let cached: Polar | null = null; + +export function getPolar(): Polar { + if (cached) return cached; + const accessToken = process.env.POLAR_ACCESS_TOKEN; + if (!accessToken) { + throw new Error('Polar is not configured (POLAR_ACCESS_TOKEN).'); + } + cached = new Polar({ + accessToken, + server: process.env.POLAR_SERVER === 'sandbox' ? 'sandbox' : 'production', + }); + return cached; +} + +const clean = (u: string) => u.replace(/\/$/, ''); + +const DEFAULT_APP_URL = 'https://ib-econgraph-ai.vercel.app'; + +/** + * Public dev-tunnel providers, mirroring `server.allowedHosts` in + * `vite.config.ts`. These are trusted only outside production (see + * `isAllowedOrigin`), where they exist so Polar redirects and webhooks can be + * tested against a real HTTPS origin. + */ +const DEV_TUNNEL_SUFFIXES = ['.devtunnels.ms', '.ngrok-free.app', '.ngrok.app', '.trycloudflare.com']; + +/** Origins this deployment is willing to redirect a checkout back to. */ +function configuredOrigins(): string[] { + const list: string[] = []; + if (process.env.APP_URL) list.push(clean(process.env.APP_URL)); + for (const extra of (process.env.ALLOWED_ORIGINS || '').split(',')) { + const trimmed = extra.trim(); + if (trimmed) list.push(clean(trimmed)); + } + return list; +} + +/** + * The checkout success/cancel URLs are handed to Polar, which redirects the + * browser there after payment. Building them from a raw `Origin` (or `Host`) + * header would let a caller point that redirect at any site they like, so every + * candidate has to clear an allowlist first. + */ +function isAllowedOrigin(candidate: string): boolean { + let url: URL; + try { + url = new URL(candidate); + } catch { + return false; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + if (configuredOrigins().includes(clean(url.origin))) return true; + + // Development conveniences, deliberately unavailable in production: a + // self-hosted production deployment must name its origins via APP_URL / + // ALLOWED_ORIGINS. + if (process.env.NODE_ENV === 'production') return false; + if (/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(url.hostname)) return true; + return DEV_TUNNEL_SUFFIXES.some((suffix) => url.hostname.endsWith(suffix)); +} + +export function getAppUrl(req: { headers: Record }): string { + // On Vercel (production or preview), prefer the configured canonical domain + // so checkout redirects land on the primary URL rather than a *.vercel.app + // alias. VERCEL is set automatically in every Vercel deployment. + if (process.env.VERCEL && process.env.APP_URL) { + return clean(process.env.APP_URL); + } + + // Otherwise send the user back to the exact origin their browser is on, as + // long as it is one we recognise. The same-origin POST to /api/checkout + // carries that origin, which stays correct even when a tunnel rewrites the + // Host header. + const origin = req.headers['origin']; + if (typeof origin === 'string' && isAllowedOrigin(origin)) return clean(origin); + + // Fallback: the forwarded/host header, subject to the same allowlist. + const host = (req.headers['x-forwarded-host'] || req.headers.host) as string | undefined; + if (host) { + const isLocal = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host); + const proto = (req.headers['x-forwarded-proto'] as string | undefined) || (isLocal ? 'http' : 'https'); + const fromHost = `${proto}://${host}`; + if (isAllowedOrigin(fromHost)) return fromHost; + } + + return clean(process.env.APP_URL || DEFAULT_APP_URL); +} diff --git a/api/_lib/supabaseAdmin.ts b/api/_lib/supabaseAdmin.ts new file mode 100644 index 0000000..cbec7cb --- /dev/null +++ b/api/_lib/supabaseAdmin.ts @@ -0,0 +1,73 @@ +import { createClient, SupabaseClient, User } from '@supabase/supabase-js'; +import type { VercelRequest } from '@vercel/node'; +import { isProUntilActive } from '../../services/entitlement'; + +let cached: SupabaseClient | null = null; + +/** + * Admin Supabase client (bypasses RLS). Server-side only — never expose the + * SUPABASE_SECRET_KEY to the browser. Uses the Supabase secret key + * (`sb_secret_…`), the modern replacement for the legacy service_role key. + */ +export function getSupabaseAdmin(): SupabaseClient { + if (cached) return cached; + const url = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL; + const key = process.env.SUPABASE_SECRET_KEY; + if (!url || !key) { + throw new Error('Supabase server environment is not configured (SUPABASE_URL / SUPABASE_SECRET_KEY).'); + } + cached = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + return cached; +} + +/** + * Validates the Bearer token from the request and returns the Supabase user, + * or null when missing/invalid. + */ +export async function getUserFromRequest(req: VercelRequest): Promise { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : ''; + if (!token) return null; + + const admin = getSupabaseAdmin(); + const { data, error } = await admin.auth.getUser(token); + if (error || !data?.user) return null; + return data.user; +} + +export interface BillingProfile { + id: string; + email: string | null; + pro_status: string; + pro_until: string | null; + polar_customer_id: string | null; + polar_subscription_id: string | null; +} + +export async function getProfile(userId: string): Promise { + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('profiles') + .select('id, email, pro_status, pro_until, polar_customer_id, polar_subscription_id') + .eq('id', userId) + .maybeSingle(); + if (error) throw new Error(`Failed to load profile: ${error.message}`); + return (data as BillingProfile) ?? null; +} + +/** Monthly hosted-AI generation cap (HOSTED_AI_MONTHLY_LIMIT, default 150). */ +export function hostedMonthlyLimit(): number { + const parsed = Number.parseInt(process.env.HOSTED_AI_MONTHLY_LIMIT || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 150; +} + +export function isProfilePro(profile: BillingProfile | null): boolean { + return isProUntilActive(profile?.pro_until); +} + +/** Current usage month in UTC, e.g. "2026-07". */ +export function currentUsageMonth(): string { + return new Date().toISOString().slice(0, 7); +} diff --git a/api/checkout.ts b/api/checkout.ts new file mode 100644 index 0000000..3bcb05d --- /dev/null +++ b/api/checkout.ts @@ -0,0 +1,85 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getUserFromRequest, getProfile, isProfilePro } from './_lib/supabaseAdmin'; +import { getPolar, getAppUrl } from './_lib/polar'; +// Subscription is live (or in dunning), a new checkout would double-charge. +import { ENTITLED_POLAR_STATUSES } from '../services/entitlement'; + +/** + * Creates a Polar checkout session for the Supporter plan and returns its URL. + * Body: { interval: 'month' | 'year' } + */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('checkout: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Please sign in first.' }); + } + + const interval = (req.body as { interval?: string } | undefined)?.interval === 'year' ? 'year' : 'month'; + const productId = interval === 'year' + ? process.env.POLAR_PRODUCT_ID_YEARLY + : process.env.POLAR_PRODUCT_ID_MONTHLY; + if (!productId) { + return res.status(503).json({ error: 'Billing is not configured on this deployment.' }); + } + + // Don't let an already-subscribed user start a second checkout (Polar would + // create a parallel subscription and double-charge them). A canceled-but-in- + // grace user (pro_until still future, status no longer active) can resubscribe. + let profile; + try { + profile = await getProfile(user.id); + } catch (err) { + console.error('checkout: profile lookup failed', err); + return res.status(503).json({ error: 'Could not verify your account right now. Please try again in a moment.' }); + } + if (profile?.polar_subscription_id && isProfilePro(profile) && ENTITLED_POLAR_STATUSES.has(profile.pro_status)) { + return res.status(409).json({ + error: 'You already have an active Supporter subscription. Manage it from Settings > Manage billing.', + code: 'already_subscribed', + }); + } + + let polar; + try { + polar = getPolar(); + } catch (err) { + console.error('checkout: Polar is not configured', err); + return res.status(503).json({ error: 'Billing is not configured on this deployment.' }); + } + + // Deliberately NOT calling polar.subscriptions.list() here to close the gap + // between a payment succeeding and its webhook landing. That check costs a + // Polar API call on every checkout attempt, on an endpoint any signed-in + // user can hit repeatedly, and it has to fail closed to be worth anything — + // so a Polar rate-limit or outage would stop all new subscriptions. It also + // would not help in the case it was meant for: during that window the + // profile has no billing data yet, which is exactly why the check above + // misses it. The double-click path is already handled client-side (the + // subscribe button disables while a checkout is in flight), leaving only a + // deliberate pay-twice-in-two-tabs case, which is refundable in Polar. + try { + const appUrl = getAppUrl(req); + const checkout = await polar.checkouts.create({ + products: [productId], + successUrl: `${appUrl}/settings?checkout=success`, + externalCustomerId: user.id, + customerEmail: user.email ?? undefined, + metadata: { supabase_user_id: user.id }, + }); + return res.status(200).json({ url: checkout.url }); + } catch (err) { + console.error('checkout: failed to create Polar checkout', err); + return res.status(502).json({ error: 'Could not start checkout. Please try again in a moment.' }); + } +} diff --git a/api/delete-account.ts b/api/delete-account.ts new file mode 100644 index 0000000..2c8fe4d --- /dev/null +++ b/api/delete-account.ts @@ -0,0 +1,112 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getSupabaseAdmin, getUserFromRequest, getProfile } from './_lib/supabaseAdmin'; +import { getPolar } from './_lib/polar'; +import { ENTITLED_POLAR_STATUSES } from '../services/entitlement'; + +/** + * Permanently deletes the signed-in user's account and all cloud data. + * + * Order matters: we cancel any active Polar subscription FIRST so a deleted + * account can't keep being charged (and if we can't cancel it, we abort rather + * than orphan a paid subscription). Then we delete the auth user, which cascades + * to every table via `on delete cascade` — profiles, projects, graphs, + * graph_versions, templates, shares, ai_usage. + */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('delete-account: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Please sign in first.' }); + } + + const admin = getSupabaseAdmin(); + + // Cancel billing before deleting, so the card is never charged for an + // account that no longer exists. If we can't even read the profile, abort + // rather than delete blind and risk orphaning a paid subscription. + let profile; + try { + profile = await getProfile(user.id); + } catch (err) { + console.error('delete-account: profile lookup failed', err); + return res.status(503).json({ + error: 'Could not verify your billing status right now. Please try again in a moment.', + }); + } + + // Ask Polar what this user actually has, rather than trusting our own row. + // A profile can be missing entirely, or its subscription id can be stale + // because a webhook was never delivered; in either case gating on our copy + // would skip cancellation and leave a live subscription billing a deleted + // account. Polar is the authority, so query it by external customer id. + let liveSubscriptionIds: string[]; + try { + const page = await getPolar().subscriptions.list({ externalCustomerId: user.id, active: true }); + const ids = new Set(); + for await (const chunk of page) { + for (const sub of chunk.result.items) { + if (ENTITLED_POLAR_STATUSES.has(sub.status ?? '')) ids.add(sub.id); + } + } + // Belt and braces: cancel anything our own row knows about too, in case + // Polar's active filter and our status set ever disagree. + if (profile?.polar_subscription_id) ids.add(profile.polar_subscription_id); + liveSubscriptionIds = [...ids]; + } catch (err) { + // Includes "Polar isn't configured on this deployment", which is a + // server problem: don't tell the user to go cancel something manually. + console.error('delete-account: could not list subscriptions', err); + return res.status(503).json({ + error: 'Could not verify your billing status right now. Please try again in a moment.', + }); + } + + for (const subId of liveSubscriptionIds) { + try { + await getPolar().subscriptions.revoke({ id: subId }); + } catch (err) { + // The revoke can fail simply because the subscription is already + // inactive on Polar (our pro_status was stale) — in that case there's + // nothing left to cancel, so re-check Polar and only trap the user if + // it's genuinely still active. + let stillActive = true; + try { + const sub = await getPolar().subscriptions.get({ id: subId }); + stillActive = ENTITLED_POLAR_STATUSES.has(sub.status ?? ''); + } catch (lookupErr) { + // Only a definite "not found" proves the subscription is gone. + // Treating any failure as gone would delete the account during a + // Polar outage and orphan a subscription that keeps charging. + const status = (lookupErr as { statusCode?: number; status?: number } | null)?.statusCode + ?? (lookupErr as { status?: number } | null)?.status; + stillActive = status !== 404; + } + if (stillActive) { + console.error('delete-account: subscription cancel failed', err); + return res.status(409).json({ + error: 'We couldn\'t cancel your active subscription automatically. Please cancel it in "Manage billing" first, then delete your account.', + code: 'cancel_failed', + }); + } + console.warn('delete-account: revoke failed but subscription is no longer active; proceeding with deletion', err); + } + } + + const { error } = await admin.auth.admin.deleteUser(user.id); + if (error) { + console.error('delete-account: deleteUser failed', error); + return res.status(500).json({ error: 'Could not delete your account. Please try again in a moment.' }); + } + + return res.status(200).json({ deleted: true }); +} diff --git a/api/generate.ts b/api/generate.ts new file mode 100644 index 0000000..e91a23b --- /dev/null +++ b/api/generate.ts @@ -0,0 +1,260 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { GoogleGenAI } from '@google/genai'; +import { + getSupabaseAdmin, + getUserFromRequest, + getProfile, + isProfilePro, + currentUsageMonth, + hostedMonthlyLimit as monthlyLimit, +} from './_lib/supabaseAdmin'; +import { + DIAGRAM_SYSTEM_INSTRUCTION, + GEMINI_DIAGRAM_SCHEMA, + buildHistoryContext, + diagramShapeError, +} from '../services/diagramPrompt'; + +const MAX_PROMPT_CHARS = 4000; +const MAX_HISTORY_ENTRIES = 40; +const MAX_HISTORY_CHARS = 24000; + +/** + * Bound the upstream model call. Without this the only limit is the platform + * function timeout, which kills the process outright, so the refund below never + * runs and the user loses a credit for a generation they never received. + * Must stay comfortably under the `maxDuration` set for this route in + * `vercel.json`. + */ +const MODEL_TIMEOUT_MS = 30_000; + +type AiConfig = { ai: GoogleGenAI; model: string }; + +/** + * Resolve the hosted-AI client from environment. Three supported backends, in + * priority order: + * + * 1. Vertex AI express mode — VERTEX_API_KEY. An API key (no service + * account), so it works anywhere including serverless like Vercel. + * 2. Vertex AI (full) — GOOGLE_CLOUD_PROJECT [+ GOOGLE_CLOUD_LOCATION]. + * Auth via Application Default Credentials locally (`gcloud auth + * application-default login`), or a service-account key placed in + * GOOGLE_SERVICE_ACCOUNT_JSON on hosts without gcloud (e.g. Vercel). + * 3. Gemini Developer API — GEMINI_API_KEY (Google AI Studio). Kept as a + * fallback so existing / fully-free deployments keep working unchanged. + * + * Returns null if none is configured. Note: "Vertex AI" was renamed + * "Gemini Enterprise Agent Platform" in 2026; the SDK flag (vertexai: true) + * is unchanged. + * + * The result is memoised at module scope: the config comes only from + * environment variables, which cannot change within a warm serverless + * container, so rebuilding the client (and re-parsing the service-account JSON) + * on every request is pure overhead. + */ +let cachedAiConfig: AiConfig | null | undefined; + +function resolveAiClient(): AiConfig | null { + if (cachedAiConfig !== undefined) return cachedAiConfig; + cachedAiConfig = buildAiClient(); + return cachedAiConfig; +} + +function buildAiClient(): AiConfig | null { + const model = process.env.HOSTED_AI_MODEL || 'gemini-2.5-flash'; + + const vertexApiKey = process.env.VERTEX_API_KEY; + if (vertexApiKey) { + return { ai: new GoogleGenAI({ vertexai: true, apiKey: vertexApiKey }), model }; + } + + const project = process.env.GOOGLE_CLOUD_PROJECT || process.env.VERTEX_PROJECT_ID; + if (project) { + const location = process.env.GOOGLE_CLOUD_LOCATION || process.env.VERTEX_LOCATION || 'global'; + const opts: ConstructorParameters[0] = { vertexai: true, project, location }; + const saJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON; + if (saJson) { + try { + opts.googleAuthOptions = { credentials: JSON.parse(saJson) }; + } catch { + // Malformed key: fall back to ADC rather than crash. If ADC is + // also absent, the generateContent call will surface the auth + // error and the request is refunded like any upstream failure. + console.error('generate: GOOGLE_SERVICE_ACCOUNT_JSON is not valid JSON; falling back to ADC.'); + } + } + return { ai: new GoogleGenAI(opts), model }; + } + + const geminiKey = process.env.GEMINI_API_KEY; + if (geminiKey) { + return { ai: new GoogleGenAI({ apiKey: geminiKey }), model }; + } + + return null; +} + +/** + * Hosted AI generation for Supporter (Pro) users. Authenticated via Supabase + * JWT, entitlement-checked, and metered per month. The Gemini API key never + * leaves the server. + */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + const aiConfig = resolveAiClient(); + if (!aiConfig) { + return res.status(503).json({ error: 'Hosted AI is not configured on this deployment.' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('generate: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Please sign in to use hosted AI.' }); + } + + const body = (req.body ?? {}) as { prompt?: unknown; history?: unknown }; + const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''; + if (!prompt) { + return res.status(400).json({ error: 'Missing prompt.' }); + } + if (prompt.length > MAX_PROMPT_CHARS) { + return res.status(400).json({ error: `Prompt is too long (max ${MAX_PROMPT_CHARS} characters).` }); + } + + let history: string[] = []; + if (Array.isArray(body.history)) { + history = body.history + .filter((h): h is string => typeof h === 'string') + // Cap each entry so a single huge string can't blow past the total + // cap and reach the hosted Gemini key. + .map((h) => h.slice(0, MAX_HISTORY_CHARS)) + .slice(-MAX_HISTORY_ENTRIES); + // Drain to the total cap, including down to the final entry. + while (history.join('\n').length > MAX_HISTORY_CHARS && history.length > 0) { + history.shift(); + } + } + + // A failed lookup is not the same as "not a Supporter" — answering 402 here + // would tell a paying user their plan lapsed during a transient DB blip. + let profile; + try { + profile = await getProfile(user.id); + } catch (err) { + console.error('generate: profile lookup failed', err); + return res.status(503).json({ + error: 'Could not confirm your plan right now. Please try again in a moment.', + }); + } + if (!isProfilePro(profile)) { + return res.status(402).json({ + error: 'Hosted AI is part of the Supporter plan. You can keep generating for free with your own API key (Settings > AI Provider).', + code: 'not_pro', + }); + } + + const admin = getSupabaseAdmin(); + const month = currentUsageMonth(); + const limit = monthlyLimit(); + + const { data: newCount, error: usageError } = await admin.rpc('increment_ai_usage', { + p_user: user.id, + p_month: month, + p_limit: limit, + }); + if (usageError) { + console.error('generate: usage metering failed', usageError); + return res.status(500).json({ error: 'Usage metering failed. Please try again.' }); + } + // Fail closed: an unexpected return type must not skip the quota check and + // hand out unmetered generations on the hosted key. + if (typeof newCount !== 'number') { + console.error('generate: increment_ai_usage returned a non-numeric result', newCount); + return res.status(500).json({ error: 'Usage metering failed. Please try again.' }); + } + if (newCount < 0) { + return res.status(429).json({ + error: `You've used all ${limit} hosted generations for this month. They reset at the start of next month, or add your own free API key in Settings for unlimited generations.`, + code: 'quota_exceeded', + usage: { used: limit, limit }, + }); + } + + let responseText: string; + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), MODEL_TIMEOUT_MS); + try { + const { ai, model } = aiConfig; + const response = await ai.models.generateContent({ + model, + contents: `${buildHistoryContext(history)} ${prompt}`, + config: { + systemInstruction: DIAGRAM_SYSTEM_INSTRUCTION, + responseMimeType: 'application/json', + responseSchema: GEMINI_DIAGRAM_SCHEMA, + temperature: 0.2, + abortSignal: abort.signal, + }, + }); + responseText = response.text || '{}'; + } catch (err) { + // The upstream call failed or timed out, so no diagram reached the user + // and the metered credit is refunded. This is the ONLY refund path: a + // response that comes back but fails to parse below still counts as a + // used generation, so it can't be farmed to burn the hosted key for + // free. (On a timeout the provider may still bill us upstream, since + // aborting is client-side only, but charging the user for nothing they + // received would be worse.) + console.error( + abort.signal.aborted + ? `generate: Gemini call exceeded ${MODEL_TIMEOUT_MS}ms and was aborted` + : 'generate: Gemini call failed', + err, + ); + await admin + .rpc('refund_ai_usage', { p_user: user.id, p_month: month }) + .then(({ error }) => { + if (error) console.error('generate: refund failed', error); + }); + return res.status(502).json({ + error: abort.signal.aborted + ? 'The AI took too long to respond. Please try again.' + : 'The AI generation failed. Please try again.', + }); + } finally { + clearTimeout(timer); + } + + try { + const diagram = JSON.parse(responseText); + // An empty/whitespace model response becomes '{}' (line above), which + // parses to {}. Anything the renderer cannot draw is rejected here: the + // response schema makes a malformed object unlikely, not impossible, and + // a partial one produces NaN geometry rather than a clear failure. Not + // refunded (a produced response counts as used), same rationale as the + // parse-failure path. + const shapeError = diagramShapeError(diagram); + if (shapeError) { + console.error(`generate: model returned an unusable diagram (${shapeError})`); + return res.status(502).json({ error: 'The AI returned an empty result. Please try again.' }); + } + return res.status(200).json({ + diagram, + usage: { used: newCount as number, limit }, + }); + } catch (err) { + // Response was produced (and billed upstream) but wasn't valid JSON. + // Not refunded, see above. Rare in practice given the response schema. + console.error('generate: could not parse model output', err); + return res.status(502).json({ error: 'The AI returned an unexpected format. Please try again.' }); + } +} diff --git a/api/portal.ts b/api/portal.ts new file mode 100644 index 0000000..0bfcf35 --- /dev/null +++ b/api/portal.ts @@ -0,0 +1,48 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getUserFromRequest } from './_lib/supabaseAdmin'; +import { getPolar } from './_lib/polar'; + +/** + * Creates a Polar customer-portal session (manage / cancel subscription, + * download invoices) and returns its URL. + */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('portal: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Please sign in first.' }); + } + + // Resolve the client outside the try: a missing POLAR_ACCESS_TOKEN is a + // deployment problem, not "you have no billing account", and telling the + // user to wait and retry would send them in circles. + let polar; + try { + polar = getPolar(); + } catch (err) { + console.error('portal: Polar is not configured', err); + return res.status(503).json({ error: 'Billing is not configured on this deployment.' }); + } + + try { + const session = await polar.customerSessions.create({ + externalCustomerId: user.id, + }); + return res.status(200).json({ url: session.customerPortalUrl }); + } catch (err) { + console.error('portal: failed to create customer session', err); + return res.status(404).json({ + error: 'No billing account found. If you just subscribed, wait a few seconds and try again.', + }); + } +} diff --git a/api/usage.ts b/api/usage.ts new file mode 100644 index 0000000..136c39d --- /dev/null +++ b/api/usage.ts @@ -0,0 +1,64 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { + getSupabaseAdmin, + getUserFromRequest, + getProfile, + isProfilePro, + currentUsageMonth, + hostedMonthlyLimit as monthlyLimit, +} from './_lib/supabaseAdmin'; + +/** Returns the signed-in user's hosted AI usage for the current month. */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('usage: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Not signed in.' }); + } + + // A lookup failure must not read as "not a Supporter": the caller would show + // a lapsed plan to someone whose plan is fine. Distinguish it from a genuine + // null profile by capturing the error. + let profileFailed = false; + const [profile, usageResult] = await Promise.all([ + getProfile(user.id).catch((err) => { + console.error('usage: profile lookup failed', err); + profileFailed = true; + return null; + }), + getSupabaseAdmin() + .from('ai_usage') + .select('count') + .eq('user_id', user.id) + .eq('month', currentUsageMonth()) + .maybeSingle(), + ]); + + // A failed lookup must not masquerade as "0 used" — that would show a + // full quota to someone who has already spent it. + if (usageResult.error) { + console.error('usage: failed to read ai_usage', usageResult.error); + return res.status(503).json({ error: 'Usage service is temporarily unavailable.' }); + } + if (profileFailed) { + return res.status(503).json({ error: 'Could not confirm your plan right now. Please try again in a moment.' }); + } + + const used = usageResult.data?.count ?? 0; + return res.status(200).json({ + used, + limit: monthlyLimit(), + month: currentUsageMonth(), + isPro: isProfilePro(profile), + }); +} diff --git a/api/webhooks/polar.ts b/api/webhooks/polar.ts new file mode 100644 index 0000000..26cee6d --- /dev/null +++ b/api/webhooks/polar.ts @@ -0,0 +1,297 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'; +import { getSupabaseAdmin } from '../_lib/supabaseAdmin'; +import { ENTITLED_POLAR_STATUSES } from '../../services/entitlement'; + +// Signature verification requires the raw request body. +export const config = { + api: { bodyParser: false }, +}; + +/** + * Safety margin (in days) added to ACTIVE access so a paying subscriber isn't + * locked out during the brief gap if Polar's renewal webhook lands slightly + * after the period end. + * + * This is NOT post-cancellation grace: when a subscription is canceled/revoked, + * the terminal event runs the non-entitled branch below and sets pro_until to + * `now`, which overrides this margin — so it never grants access after a + * cancellation. It only cushions the renewal boundary for continuing subscribers. + */ +const ACTIVE_MARGIN_DAYS = 1; + + +function readRawBody(req: VercelRequest): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +interface SubscriptionLike { + id: string; + status: string; + currentPeriodEnd?: Date | null; + recurringInterval?: string | null; + customerId?: string; + customer?: { id?: string; externalId?: string | null } | null; + /** When Polar last changed this subscription. Used to order deliveries. */ + modifiedAt?: Date | null; + createdAt?: Date | null; + /** Set when the user has cancelled but keeps access to the end of the paid period. */ + cancelAtPeriodEnd?: boolean | null; + /** The definitive end of access once cancellation is scheduled. */ + endsAt?: Date | null; +} + +function toDate(value: unknown): Date | null { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (typeof value === 'string') { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; +} + +/** Epoch millis, or null for anything absent or unparseable. Never NaN. */ +function millis(value: unknown): number | null { + return toDate(value)?.getTime() ?? null; +} + +/** + * Ordering key for an event. Webhook deliveries are not ordered and are + * retried, so "the event that arrived last" is not "the event that happened + * last". Polar stamps every subscription change with `modifiedAt`; a freshly + * created subscription has none yet, so `createdAt` stands in. + * + * Returns null when neither is usable, in which case the caller falls back to + * applying the event unordered (better than dropping billing state entirely). + */ +function eventTimestamp(sub: SubscriptionLike): Date | null { + for (const candidate of [sub.modifiedAt, sub.createdAt]) { + if (candidate instanceof Date && !Number.isNaN(candidate.getTime())) return candidate; + // The SDK parses these into Dates, but a hand-built payload may carry strings. + if (typeof candidate === 'string') { + const parsed = new Date(candidate); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + } + return null; +} + +/** What the profile row currently says about this user's billing. */ +export interface CurrentBillingState { + polar_subscription_id?: string | null; + pro_until?: string | null; + polar_event_at?: string | null; +} + +export type EntitlementDecision = + | { action: 'skip'; reason: string } + | { action: 'apply'; proUntil: string; eventAt: string | null }; + +/** + * Decide what an incoming subscription event should do to a profile. Pure, so + * the ordering and entitlement rules below can be exercised directly instead of + * only through a live webhook against real billing. + * + * `now` is injected for the same reason. + */ +export function decideEntitlement( + sub: SubscriptionLike, + current: CurrentBillingState | null, + now: number = Date.now(), +): EntitlementDecision { + const entitled = ENTITLED_POLAR_STATUSES.has(sub.status); + const onFile = current?.polar_subscription_id; + const differentSub = !!onFile && onFile !== sub.id; + const DAY_MS = 24 * 60 * 60 * 1000; + // `CurrentBillingState` is a plain interface, so nothing guarantees these + // two are parseable the way the timestamptz columns they normally come from + // would. A NaN reaching `Math.max` below makes `new Date(...).toISOString()` + // throw, and a webhook that throws is one Polar retries forever. + const currentEnd = millis(current?.pro_until) ?? 0; + + // Deliveries are neither ordered nor deduplicated. Checking only that the + // subscription id matches (as this used to) left the worst case open: a + // delayed `subscription.active` for the SAME subscription, arriving after a + // cancellation, passed every guard and the `Math.max` below then restored + // the future pro_until. Comparing the event's own timestamp against the + // last one applied rejects it. + const eventAt = eventTimestamp(sub); + const appliedAt = millis(current?.polar_event_at); + if (eventAt && appliedAt !== null && eventAt.getTime() < appliedAt) { + return { + action: 'skip', + reason: `event for ${sub.id} is older (${eventAt.toISOString()}) than the last applied (${current!.polar_event_at})`, + }; + } + const eventAtIso = eventAt ? eventAt.toISOString() : null; + + let proUntil: string; + if (entitled) { + // Polar keeps a subscription `active` after the user schedules a + // cancellation; it just stops renewing. Access through the period they + // already paid for is correct and deliberate, but `endsAt` is then the + // authoritative end date, and the renewal margin must not apply: that + // margin exists to cover the gap before a *renewal* webhook lands, and + // a subscription that will not renew has no such gap. Adding it would + // hand out a day of access nobody paid for. + const endsAt = toDate(sub.endsAt); + const scheduledToEnd = sub.cancelAtPeriodEnd === true || !!endsAt; + const periodEnd = endsAt ?? toDate(sub.currentPeriodEnd); + + // A malformed event with no usable period end must not lock out an + // entitled user: fall back to a short provisional window (a later, + // well-formed event corrects it) rather than "now", which reads as expired. + const candidate = periodEnd + ? periodEnd.getTime() + (scheduledToEnd ? 0 : ACTIVE_MARGIN_DAYS * DAY_MS) + : now + 2 * DAY_MS; + + // A delayed/retried event from a different (older) subscription must not + // shorten access the user has via the current one — only let a different + // subscription take over if it actually extends access. + if (differentSub && candidate <= currentEnd) { + return { + action: 'skip', + reason: `stale entitled event for ${sub.id}; ${onFile} on file runs at least as long`, + }; + } + // Normally never move a still-entitled user's access backward. A + // scheduled cancellation is the exception: it legitimately shortens + // access (dropping the margin, or moving to an earlier endsAt), and the + // event-ordering check above already rejects genuinely stale deliveries, + // which is what this guard used to be protecting against. + proUntil = new Date(scheduledToEnd ? candidate : Math.max(candidate, currentEnd)).toISOString(); + } else { + // canceled / revoked / unpaid / incomplete → access ends now, but only + // for the subscription currently on file (never for a stale old one). + if (differentSub) { + return { + action: 'skip', + reason: `${sub.status} for stale subscription ${sub.id} (current is ${onFile})`, + }; + } + proUntil = new Date(now).toISOString(); + } + + return { action: 'apply', proUntil, eventAt: eventAtIso }; +} + +async function applySubscriptionState(sub: SubscriptionLike): Promise { + const userId = sub.customer?.externalId; + if (!userId) { + // Checkout created outside the app (no external customer id) — nothing to map to. + console.warn(`polar webhook: subscription ${sub.id} has no external customer id, skipping`); + return; + } + + const admin = getSupabaseAdmin(); + + // Read what's currently on file so out-of-order or superseded events can't + // clobber the state the user is actually in. + const { data: current, error: currentError } = await admin + .from('profiles') + .select('polar_subscription_id, pro_until, polar_event_at') + .eq('id', userId) + .maybeSingle(); + if (currentError) { + // Without the current row we can't tell a superseded event from a live + // one. Throwing makes the handler answer 500 so Polar retries, which is + // safer than guessing and possibly revoking an active subscription. + throw new Error(`could not read profile ${userId}: ${currentError.message}`); + } + + const decision = decideEntitlement(sub, current as CurrentBillingState | null); + if (decision.action === 'skip') { + console.log(`polar webhook: ignoring ${decision.reason}`); + return; + } + const { proUntil, eventAt: eventAtIso } = decision; + + // The read above and this write are separate round trips, so two concurrent + // deliveries for the same user can each compute from the same snapshot and + // the slower write wins regardless of which event is newer. Repeating the + // ordering test as a predicate on the UPDATE makes the decision atomic: a + // handler whose event has been overtaken matches no row and writes nothing. + // `lte` rather than `lt` so a retry of the very same event is idempotent. + let query = admin + .from('profiles') + .update({ + pro_status: sub.status, + pro_until: proUntil, + plan_interval: sub.recurringInterval ?? null, + polar_customer_id: sub.customer?.id ?? sub.customerId ?? null, + polar_subscription_id: sub.id, + polar_event_at: eventAtIso, + updated_at: new Date().toISOString(), + }) + .eq('id', userId); + if (eventAtIso) { + query = query.or(`polar_event_at.is.null,polar_event_at.lte.${eventAtIso}`); + } + // `select` so a zero-row result is distinguishable from a successful write. + const { data: updated, error } = await query.select('id'); + + if (error) { + // Throw so Polar retries the delivery. + throw new Error(`Failed to update profile ${userId}: ${error.message}`); + } + if (!updated || updated.length === 0) { + // Either the profile row is gone (deleted account) or a newer event won + // the race. Neither is retryable, so ack rather than throwing. + console.log(`polar webhook: no row updated for ${userId} (${sub.id}); a newer event or a deleted account`); + return; + } + console.log(`polar webhook: ${userId} → status=${sub.status} pro_until=${proUntil}`); +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + const secret = process.env.POLAR_WEBHOOK_SECRET; + if (!secret) { + console.error('polar webhook: POLAR_WEBHOOK_SECRET is not set'); + return res.status(503).json({ error: 'Webhook not configured' }); + } + + let event; + try { + const raw = await readRawBody(req); + event = validateEvent(raw, req.headers as Record, secret); + } catch (err) { + if (err instanceof WebhookVerificationError) { + return res.status(403).json({ error: 'Invalid signature' }); + } + console.error('polar webhook: failed to parse event', err); + return res.status(400).json({ error: 'Invalid payload' }); + } + + try { + switch (event.type) { + case 'subscription.created': + case 'subscription.active': + case 'subscription.updated': + case 'subscription.canceled': + case 'subscription.uncanceled': + case 'subscription.revoked': + case 'subscription.past_due': + await applySubscriptionState(event.data as unknown as SubscriptionLike); + break; + default: + // Ack everything else (order.*, checkout.*, customer.*) — subscription + // events carry all the entitlement state we need. + break; + } + return res.status(202).json({ received: true }); + } catch (err) { + console.error(`polar webhook: handler failed for ${event.type}`, err); + // Non-2xx → Polar retries with backoff. + return res.status(500).json({ error: 'Webhook processing failed' }); + } +} diff --git a/components/AccountSection.tsx b/components/AccountSection.tsx new file mode 100644 index 0000000..6db011d --- /dev/null +++ b/components/AccountSection.tsx @@ -0,0 +1,512 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + User as UserIcon, LogOut, Crown, Cloud, CloudOff, RefreshCw, Check, + Sparkles, ExternalLink, Loader2, Heart, Lock, Trash2, +} from 'lucide-react'; +import { useAuth } from '../services/auth'; +import { openBillingPortal, deleteAccount } from '../services/billing'; +import { fetchHostedUsage, HostedUsage } from '../services/hostedAi'; +import { SyncState } from '../services/useCloudSync'; +import AuthModal from './AuthModal'; + +interface AccountSectionProps { + syncState: SyncState; + onSyncNow: () => void; + onOpenPricing: () => void; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString([], { year: 'numeric', month: 'long', day: 'numeric' }); +} + +function formatSyncTime(ts: number | null): string { + if (!ts) return 'not yet'; + const secs = Math.round((Date.now() - ts) / 1000); + if (secs < 5) return 'just now'; + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins} min ago`; + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +/** + * "Account & Cloud" card for the Settings page: sign-in, plan status, + * hosted AI usage, sync controls, and supporter recognition. + */ +const AccountSection: React.FC = ({ syncState, onSyncNow, onOpenPricing }) => { + const { configured, loading, user, profile, isPro, recoveryMode, signOut, refreshProfile, updateProfile, updatePassword } = useAuth(); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [showPwForm, setShowPwForm] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const [pwBusy, setPwBusy] = useState(false); + const [pwError, setPwError] = useState(null); + const [pwSaved, setPwSaved] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [deleteBusy, setDeleteBusy] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [usage, setUsage] = useState(null); + const [portalLoading, setPortalLoading] = useState(false); + const [portalError, setPortalError] = useState(null); + const [supporterName, setSupporterName] = useState(''); + const [showInSupporters, setShowInSupporters] = useState(false); + const [supporterSaved, setSupporterSaved] = useState(false); + const [supporterBusy, setSupporterBusy] = useState(false); + const [checkoutPending, setCheckoutPending] = useState(false); + const [checkoutSuccess, setCheckoutSuccess] = useState(false); + const [checkoutDelayed, setCheckoutDelayed] = useState(false); + const pollRef = useRef(null); + const pollAttemptsRef = useRef(0); + + // Load profile-backed form state + useEffect(() => { + setSupporterName(profile?.supporter_name ?? ''); + setShowInSupporters(profile?.show_in_supporters ?? false); + }, [profile?.supporter_name, profile?.show_in_supporters]); + + // Hosted usage meter + useEffect(() => { + if (!user || !isPro) { + setUsage(null); + return; + } + // Ignore a response that arrives after the account changed, otherwise + // the meter can show the previous account's generation count. + let cancelled = false; + fetchHostedUsage().then((u) => { if (!cancelled) setUsage(u); }); + return () => { cancelled = true; }; + }, [user, isPro]); + + // Checkout return flow: ?checkout=success → poll until webhook lands + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('checkout') !== 'success') return; + pollAttemptsRef.current = 0; + setCheckoutPending(true); + // Clean the URL so refreshes don't re-trigger + window.history.replaceState({}, '', window.location.pathname); + }, []); + + useEffect(() => { + if (!checkoutPending) return; + if (isPro) { + setCheckoutPending(false); + setCheckoutDelayed(false); + setCheckoutSuccess(true); + return; + } + // The count lives in a ref, not a local: this effect depends on + // refreshProfile, so anything that re-creates that callback restarts the + // effect. With a local counter the restart would reset the tally and the + // "taking longer" fallback could never be reached. + pollRef.current = window.setInterval(() => { + pollAttemptsRef.current += 1; + refreshProfile(); + if (pollAttemptsRef.current > 20) { + // ~60s with no webhook yet, surface an explicit "taking longer" + // state (with a manual Check button) instead of a stuck spinner. + setCheckoutDelayed(true); + if (pollRef.current) window.clearInterval(pollRef.current); + pollRef.current = null; + } + }, 3000); + return () => { + if (pollRef.current) window.clearInterval(pollRef.current); + pollRef.current = null; + }; + }, [checkoutPending, isPro, refreshProfile]); + + // Auto-dismiss the success banner after a few seconds. + useEffect(() => { + if (!checkoutSuccess) return; + const t = window.setTimeout(() => setCheckoutSuccess(false), 8000); + return () => window.clearTimeout(t); + }, [checkoutSuccess]); + + const handlePortal = useCallback(async () => { + setPortalLoading(true); + setPortalError(null); + const result = await openBillingPortal(); + setPortalLoading(false); + if (result.url) { + window.location.href = result.url; + } else { + setPortalError(result.error ?? 'Could not open the billing portal.'); + } + }, []); + + // A password-reset link lands here in recovery mode, open the form. + useEffect(() => { + if (recoveryMode) { setShowPwForm(true); setPwError(null); } + }, [recoveryMode]); + + const handleSetPassword = useCallback(async () => { + if (newPassword.length < 8) { setPwError('Password must be at least 8 characters.'); return; } + setPwBusy(true); + setPwError(null); + const result = await updatePassword(newPassword); + setPwBusy(false); + if (result.error) { setPwError(result.error); return; } + setNewPassword(''); + setShowPwForm(false); + setPwSaved(true); + setTimeout(() => setPwSaved(false), 2500); + }, [newPassword, updatePassword]); + + const handleDeleteAccount = useCallback(async () => { + setDeleteBusy(true); + setDeleteError(null); + const result = await deleteAccount(); + if (result.error) { + setDeleteBusy(false); + setDeleteError(result.error); + return; + } + // Account is gone, clear the now-invalid session and local caches. + await signOut(); + }, [signOut]); + + const handleSaveSupporter = useCallback(async () => { + // Without this guard, clicking Save twice in quick succession fires two + // overlapping updates and whichever reply lands last wins, so the older + // value can end up persisted. Same pattern as the password and + // account-deletion actions. + if (supporterBusy) return; + setSupporterBusy(true); + try { + const result = await updateProfile({ + supporter_name: supporterName.trim() || null, + show_in_supporters: showInSupporters, + }); + if (!result.error) { + setSupporterSaved(true); + setTimeout(() => setSupporterSaved(false), 2000); + } + } finally { + setSupporterBusy(false); + } + }, [supporterBusy, supporterName, showInSupporters, updateProfile]); + + if (!configured) return null; + + return ( +
+ setAuthModalOpen(false)} /> + +
+
+
+ +
+
+

Account & Cloud

+

Sync your diagrams across devices, share links, and hosted AI

+
+
+
+ +
+ {checkoutPending && !isPro && !checkoutDelayed && ( +
+ + Payment received, activating your Supporter plan. This usually takes a few seconds. +
+ )} + {checkoutPending && !isPro && checkoutDelayed && ( +
+ + Payment received, activation is taking longer than usual. It will complete + automatically; you can check again or reload this page. + + +
+ )} + {checkoutSuccess && isPro && ( +
+ + You're a Supporter now, thank you! Cloud sync and hosted AI are active. +
+ )} + + {loading ? ( +
+ Loading account… +
+ ) : !user ? ( +
+

+ You're not signed in. Everything you need to finish your IA works without an + account, sign in only if you want cloud sync, + share links, or hosted AI (Supporter plan). +

+
+ + +
+
+ ) : ( + <> + {/* Identity + plan */} +
+
+
{user.email}
+ {isPro ? ( +
+ + Supporter{profile?.plan_interval === 'year' ? ' (yearly)' : profile?.plan_interval === 'month' ? ' (monthly)' : ''} + {profile?.pro_until && · renews/expires {formatDate(profile.pro_until)}} +
+ ) : ( +
Free plan, unlimited local diagrams, BYOK AI, full exports
+ )} +
+
+ {isPro ? ( + + ) : ( + + )} + +
+
+ {portalError && ( +
{portalError}
+ )} + + {/* Hosted AI usage */} + {isPro && usage && ( +
+
+
+ + Hosted AI generations this month +
+ {usage.used} / {usage.limit} +
+
+
0.9 ? 'bg-amber-500' : 'bg-purple-500'}`} + style={{ width: `${Math.min(100, (usage.used / usage.limit) * 100)}%` }} + /> +
+

+ Resets monthly. Your own API key (BYOK) is always unlimited and free. +

+
+ )} + + {/* Sync status */} +
+
+ {isPro ? ( + syncState.status === 'error' + ? + : + ) : ( + + )} +
+
Cloud sync
+
+ {!isPro + ? 'Supporter feature, protects your IA from a cleared browser cache' + : syncState.status === 'syncing' + ? 'Syncing…' + : syncState.status === 'error' + ? (syncState.error ?? 'Sync error') + : syncState.status === 'offline' + ? 'Offline, will retry when back online' + : `Synced ${formatSyncTime(syncState.lastSyncedAt)}`} +
+
+
+ {isPro && ( + + )} +
+ + {/* Password */} +
+
+
+ + Password +
+ {!showPwForm && ( + + )} +
+ {recoveryMode && ( +

+ Choose a new password to finish resetting your account. +

+ )} + {showPwForm && ( +
+ setNewPassword(e.target.value)} + autoComplete="new-password" + placeholder="New password (min 8 characters)" + className="flex-1 min-w-48 px-3 py-2 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> + + {!recoveryMode && ( + + )} +
+ )} + {pwError &&

{pwError}

} +
+ + {/* Supporter recognition */} + {isPro && ( +
+
+ + Supporter recognition +
+

+ Optionally list your name in the project README's supporters section. Leave blank to stay anonymous. +

+
+ setSupporterName(e.target.value)} + maxLength={50} + placeholder="Name to display (optional)" + className="flex-1 min-w-48 px-3 py-2 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> + + +
+
+ )} + + {/* Danger zone, delete account + all cloud data */} +
+
+ + Delete account +
+

+ Permanently deletes your account and all cloud-synced data (projects, graphs, + version history, templates, share links) and cancels any active subscription. + This can't be undone. Diagrams stored locally on this device are not affected. +

+ {!deleteConfirm ? ( + + ) : ( +
+

Are you sure? This is permanent.

+
+ + +
+
+ )} + {deleteError &&

{deleteError}

} +
+ + )} +
+
+ ); +}; + +export default AccountSection; diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx new file mode 100644 index 0000000..694eed3 --- /dev/null +++ b/components/AuthModal.tsx @@ -0,0 +1,275 @@ +import React, { useState, useEffect } from 'react'; +import { Mail, Lock, Eye, EyeOff, Check, Loader2, LogIn, UserPlus, ArrowLeft } from 'lucide-react'; +import { Modal } from './Modal'; +import { useAuth } from '../services/auth'; + +interface AuthModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; + message?: string; + /** + * In-app path to return to after a redirect-based sign-in (Google OAuth, or + * the emailed signup confirmation). Defaults to `/settings`. Callers that + * gate an action behind sign-in should pass their own page, otherwise the + * user lands somewhere they cannot resume from. + */ + returnTo?: string; +} + +const MIN_PASSWORD = 8; +type View = 'signin' | 'signup' | 'forgot' | 'confirm-sent' | 'reset-sent'; + +/** + * Sign-in dialog: email + password (with one-time email confirmation on signup) + * and Google OAuth. Creating an account is free, it's the prerequisite for + * checkout and Supporter features. Password login keeps email volume low, which + * matters on Supabase's rate-limited default mailer; Google sends none at all. + */ +export const AuthModal: React.FC = ({ + isOpen, + onClose, + title = 'Sign in', + message, + returnTo, +}) => { + const { signInWithPassword, signUpWithPassword, resetPassword, signInWithGoogle } = useAuth(); + const [view, setView] = useState('signin'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Reset the flow each time the modal opens so a second open never shows a + // stale success/confirmation screen from a previous attempt. + useEffect(() => { + if (isOpen) { + setView('signin'); + setPassword(''); + setShowPassword(false); + setError(null); + } + }, [isOpen]); + + const handleSignIn = async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || !email.trim() || !password) return; + setBusy(true); + setError(null); + const result = await signInWithPassword(email, password); + setBusy(false); + if (result.error) setError(result.error); + else onClose(); + }; + + const handleSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || !email.trim()) return; + if (password.length < MIN_PASSWORD) { + setError(`Password must be at least ${MIN_PASSWORD} characters.`); + return; + } + setBusy(true); + setError(null); + const result = await signUpWithPassword(email, password, returnTo); + setBusy(false); + if (result.error) setError(result.error); + else if (result.needsConfirmation) setView('confirm-sent'); + else onClose(); // confirmation disabled → signed in immediately + }; + + const handleForgot = async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || !email.trim()) return; + setBusy(true); + setError(null); + const result = await resetPassword(email); + setBusy(false); + if (result.error) setError(result.error); + else setView('reset-sent'); + }; + + const handleGoogle = async () => { + setError(null); + const result = await signInWithGoogle(returnTo); + if (result.error) setError(result.error); + }; + + // ---- "email sent" confirmation screens ------------------------------- + if (view === 'confirm-sent' || view === 'reset-sent') { + const isConfirm = view === 'confirm-sent'; + return ( + +
+
+ +
+

Check your inbox

+

+ {/* Worded to be true whether or not the address was already + registered: signup deliberately does not reveal which, + so this screen must not either. */} + {isConfirm + ? <>We sent an email to {email}. Open the link in it to verify your account, then sign in. If you already have an account with this address, sign in instead. + : <>We sent a password-reset link to {email}. Open it to choose a new password.} +

+ +
+
+ ); + } + + // ---- forgot-password form -------------------------------------------- + if (view === 'forgot') { + return ( + +
+

+ Enter your email and we'll send you a link to set a new password. +

+
+ + setEmail(e.target.value)} + placeholder="you@school.org" + aria-label="Email address" + className="w-full pl-9 pr-3 py-3 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> +
+ + {error &&
{error}
} + +
+
+ ); + } + + // ---- sign-in / sign-up form ------------------------------------------ + const isSignup = view === 'signup'; + return ( + +
+ {message &&

{message}

} + +
+
+ + setEmail(e.target.value)} + placeholder="you@school.org" + aria-label="Email address" + className="w-full pl-9 pr-3 py-3 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> +
+
+ + setPassword(e.target.value)} + placeholder={isSignup ? `Password (min ${MIN_PASSWORD} characters)` : 'Password'} + aria-label="Password" + className="w-full pl-9 pr-10 py-3 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> + +
+ {!isSignup && ( +
+ +
+ )} + +
+ +
+
+ or +
+
+ + + + {error && ( +
{error}
+ )} + +

+ {isSignup ? 'Already have an account?' : "Don't have an account?"}{' '} + +

+ +

+ Accounts are free. You only need one for cloud features, the editor, + exports, and AI with your own key work without signing in. +

+
+ + ); +}; + +export default AuthModal; diff --git a/components/CloudHistoryModal.tsx b/components/CloudHistoryModal.tsx new file mode 100644 index 0000000..79dbb54 --- /dev/null +++ b/components/CloudHistoryModal.tsx @@ -0,0 +1,112 @@ +import React, { useState, useEffect } from 'react'; +import { History, Loader2, RotateCcw, CloudOff } from 'lucide-react'; +import { Modal } from './Modal'; +import { useAuth } from '../services/auth'; +import { fetchGraphVersions, CloudVersion } from '../services/customTemplates'; +import { DiagramData, Graph } from '../types'; + +interface CloudHistoryModalProps { + isOpen: boolean; + onClose: () => void; + graph: Graph | null; + onRestore: (diagramData: DiagramData) => void; +} + +function formatWhen(iso: string): string { + const date = new Date(iso); + const today = new Date(); + const sameDay = date.toDateString() === today.toDateString(); + return sameDay + ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +/** + * Cloud version history (Supporter feature). Lists synced snapshots of the + * active graph and restores the diagram content of a chosen version. + */ +export const CloudHistoryModal: React.FC = ({ isOpen, onClose, graph, onRestore }) => { + const { user, isPro } = useAuth(); + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(false); + + // Keyed on the ids, not the objects: `activeGraph` in App.tsx is a useMemo + // over `graphs` and the Supabase User is replaced on every token refresh, + // so with the objects in the deps this re-queried the version list while the + // user was simply editing the diagram with the modal open. + const graphId = graph?.id ?? null; + const userId = user?.id ?? null; + useEffect(() => { + // Clear first: otherwise the previous graph's snapshots stay listed + // until the new query resolves, and restoring one would write another + // diagram's content into this graph. + setVersions([]); + if (!isOpen || !graphId || !userId || !isPro) return; + let cancelled = false; + setLoading(true); + fetchGraphVersions(graphId) + .then((v) => { if (!cancelled) setVersions(v); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [isOpen, graphId, userId, isPro]); + + const handleRestore = (version: CloudVersion) => { + const data = version.data as Graph | null; + if (data && typeof data === 'object' && data.diagramData) { + onRestore(data.diagramData); + onClose(); + } + }; + + return ( + + {!user || !isPro ? ( +
+ +

Version history is part of the Supporter plan and needs cloud sync to be active.

+
+ ) : loading ? ( +
+ +
+ ) : versions.length === 0 ? ( +
+ +

No cloud versions yet. Versions are saved automatically every time this graph syncs.

+
+ ) : ( +
+

+ Restoring replaces the current diagram (your chat history is kept). You can undo with Ctrl+Z. +

+ {versions.map((version, i) => ( +
+
+ +
+
+
+ {version.title || 'Untitled graph'} + {i === 0 && latest} +
+
{formatWhen(version.createdAt)}
+
+ +
+ ))} +
+ )} +
+ ); +}; + +export default CloudHistoryModal; diff --git a/components/ComparePage.tsx b/components/ComparePage.tsx new file mode 100644 index 0000000..06fe9fd --- /dev/null +++ b/components/ComparePage.tsx @@ -0,0 +1,278 @@ +import React from 'react'; +import { + BarChart2, Check, X, Minus, Github, ArrowRight, ShieldCheck, Info, +} from 'lucide-react'; + +interface ComparePageProps { + onOpenEditor: () => void; + onOpenLanding: () => void; + onOpenPricing: () => void; +} + +type CellValue = { kind: 'yes' | 'no' | 'partial'; text: string }; + +const yes = (text: string): CellValue => ({ kind: 'yes', text }); +const no = (text: string): CellValue => ({ kind: 'no', text }); +const partial = (text: string): CellValue => ({ kind: 'partial', text }); + +// Competitor facts verified against their live sites on 2026-07-17. +// EconGraph Pro: econgraphs.diplomacollective.com (Diploma Collective) +// EconDiagrams: econdiagrams.com (EconDaddy.com Ltd., in beta) +const ROWS: { label: string; us: CellValue; egp: CellValue; ed: CellValue }[] = [ + { + label: 'Price to create & export a diagram', + us: yes('Free, forever, no watermark'), + egp: no('Paid membership required to download or save ($2/mo at checkout; their site also shows $1.66/mo)'), + ed: partial('Free tier exports images, capped at 3 diagrams'), + }, + { + label: 'Diagram limit on the free tier', + us: yes('Unlimited diagrams & projects'), + egp: no('None savable, downloads and saving are fully paywalled'), + ed: no('3 diagrams, 1 whiteboard, 1 collection'), + }, + { + label: 'AI diagram generation', + us: yes('Yes, free with your own key, or hosted on the Supporter plan'), + egp: no('No AI features'), + ed: no('No AI features'), + }, + { + label: 'Export formats', + us: yes('SVG, PNG, and JPEG at full quality'), + egp: partial('Single "Download Diagram" button, behind the paywall (formats unverified)'), + ed: partial('"Export as image" (format unspecified)'), + }, + { + label: 'Works without an account', + us: yes('Yes, no sign-up to create or export'), + egp: partial('Can edit without an account, but paid account needed to download'), + ed: no('Email registration required'), + }, + { + label: 'Diagram coverage', + us: yes('Any IB diagram, freeform tools, 15+ templates, and AI for the rest'), + egp: no('5 diagram types live (a 6th marked "Coming Soon")'), + ed: yes('40+ IB-aligned templates claimed (site also says 50+)'), + }, + { + label: 'Your data stays on your device', + us: yes('Local-first, cloud sync is optional'), + egp: no('Cloud-based'), + ed: no('Cloud-based'), + }, + { + label: 'Open source', + us: yes('AGPL-3.0 licensed, audit it, fork it, self-host it'), + egp: no('Proprietary'), + ed: no('Proprietary'), + }, + { + label: 'Product status', + us: yes('Live and actively maintained'), + egp: yes('Live'), + ed: partial('Public beta (paid plan invite-only, unpriced)'), + }, +]; + +const CellIcon: React.FC<{ kind: CellValue['kind'] }> = ({ kind }) => { + if (kind === 'yes') { + return ( +
+ +
+ ); + } + if (kind === 'no') { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); +}; + +const ComparePage: React.FC = ({ onOpenEditor, onOpenLanding, onOpenPricing }) => { + return ( +
+ {/* Nav */} + + + {/* Hero */} +
+
+
+ + An honest comparison +
+

+ How IB EconGraph AI compares +

+

+ The two tools IB Economics students most often consider are{' '} + EconGraph Pro (Diploma Collective) and{' '} + EconDiagrams (EconDaddy). Here's the honest, factual breakdown. +

+
+
+ + {/* Comparison table */} +
+
+ {/* The table is wider than a phone viewport, so the wrapper + scrolls horizontally. tabIndex makes that scroll reachable + without a pointer; the role/label give the focus stop a name. */} +
+ + + + + + + + + + + {ROWS.map((row, i) => ( + + + + + + + ))} + +
+ Feature + + IB EconGraph AI +
this tool
+
+ EconGraph Pro +
Diploma Collective
+
+ EconDiagrams +
EconDaddy · beta
+
{row.label} +
+ + {row.us.text} +
+
+
+ + {row.egp.text} +
+
+
+ + {row.ed.text} +
+
+
+ +
+ +

+ Based on each product's publicly visible website and app as of July 17, 2026. Details behind + paywalls or logins are marked unverified. Products may change, so check their sites for current + terms. Spotted an inaccuracy?{' '} + + Open an issue + {' '} + and it will be corrected. +

+
+
+
+ + {/* CTA */} +
+
+

+ Try it now! +

+ You got nothing to lose, literally +

+

+ No account, no card, no watermark. Your first exam-ready diagram is 30 seconds away. +

+
+ + +
+
+
+ + {/* Footer */} +
+
+ +
+ + + GitHub + + AGPL-3.0 +
+
+
+
+ ); +}; + +export default ComparePage; diff --git a/components/ComponentLibrary.tsx b/components/ComponentLibrary.tsx index b04da24..ca14eb6 100644 --- a/components/ComponentLibrary.tsx +++ b/components/ComponentLibrary.tsx @@ -1,16 +1,23 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { TrendingDown, TrendingUp, Activity, Minus, ArrowDownRight, ArrowUp, Triangle, AlertTriangle, Square, Target, Circle, BarChart2, Crown, Receipt, - ChevronDown, ChevronRight, Search, Plus, X, Package + ChevronDown, ChevronRight, Search, Plus, X, Package, Star, Trash2, Loader2, BookmarkPlus } from 'lucide-react'; -import { ComponentTemplate, COMPONENT_TEMPLATES } from '../types'; +import { ComponentTemplate, COMPONENT_TEMPLATES, DiagramData } from '../types'; +import { ConfirmModal } from './Modal'; import { usePortalTooltip } from './usePortalTooltip'; +import { useAuth } from '../services/auth'; +import { + CustomTemplate, listCachedTemplates, fetchCustomTemplates, + saveCustomTemplate, deleteCustomTemplate, templateDataFromDiagram, +} from '../services/customTemplates'; export interface ComponentLibraryProps { onAddComponent: (template: ComponentTemplate) => void; isOpen: boolean; onClose: () => void; + currentDiagram: DiagramData; } const iconMap: Record = { @@ -41,14 +48,101 @@ const ComponentLibrary: React.FC = ({ onAddComponent, isOpen, onClose, + currentDiagram, }) => { const [searchTerm, setSearchTerm] = useState(''); - const [expandedCategories, setExpandedCategories] = useState(['curves', 'areas', 'points', 'complete']); + const [expandedCategories, setExpandedCategories] = useState(['custom', 'curves', 'areas', 'points', 'complete']); const { showTooltip, hideTooltip, TooltipPortal } = usePortalTooltip({ delay: 400, placement: 'left' }); + // ── Custom templates (Supporter feature, synced) ── + const { configured: cloudConfigured, user, isPro } = useAuth(); + const [customTemplates, setCustomTemplates] = useState([]); + const [showSaveForm, setShowSaveForm] = useState(false); + const [saveName, setSaveName] = useState(''); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + // Template awaiting delete confirmation (null when the dialog is closed). + const [pendingDelete, setPendingDelete] = useState(null); + + // Only ever show templates belonging to the signed-in user; clear when + // signed out so a previous user's cache never leaks on a shared browser. + useEffect(() => { + if (!user) { + setCustomTemplates([]); + return; + } + setCustomTemplates(listCachedTemplates(user.id)); + if (isOpen && isPro) { + // Drop a response that lands after sign-out or an account switch, + // which would otherwise repopulate the library from the old account. + let cancelled = false; + fetchCustomTemplates(user.id).then((t) => { if (!cancelled) setCustomTemplates(t); }); + return () => { cancelled = true; }; + } + }, [isOpen, user, isPro]); + if (!isOpen) return null; + const handleSaveTemplate = async () => { + // The button is disabled for these, but Enter in the name field calls + // this directly, so repeated presses could create duplicate templates. + if (saving || !saveName.trim()) return; + if (!user) { + setSaveError('Sign in (Settings) to save templates.'); + return; + } + setSaving(true); + setSaveError(null); + const result = await saveCustomTemplate(user.id, { + name: saveName, + data: templateDataFromDiagram(currentDiagram), + }); + setSaving(false); + if (result.error) { + setSaveError(result.error); + } else if (result.template) { + setCustomTemplates(prev => [result.template!, ...prev]); + setShowSaveForm(false); + setSaveName(''); + } + }; + + const handleDeleteTemplate = async (id: string) => { + if (!user) return; + const prevList = customTemplates; + setCustomTemplates(prev => prev.filter(t => t.id !== id)); + const { error } = await deleteCustomTemplate(user.id, id); + if (error) { + setCustomTemplates(prevList); // roll back the optimistic removal + setSaveError(error); + } + }; + + // Deleting a synced template removes it from every device, so confirm first + // (same pattern as the destructive actions in Settings). + const confirmDeleteTemplate = () => { + if (!pendingDelete) return; + handleDeleteTemplate(pendingDelete.id); + setPendingDelete(null); + }; + + const addCustomTemplate = (t: CustomTemplate) => { + onAddComponent({ + id: t.id, + name: t.name, + description: t.description, + category: 'complete', + icon: 'Star', + data: t.data, + }); + }; + + const filteredCustom = customTemplates.filter( + t => t.name.toLowerCase().includes(searchTerm.toLowerCase()) || + t.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + const toggleCategory = (category: string) => { setExpandedCategories(prev => prev.includes(category) @@ -100,6 +194,122 @@ const ComponentLibrary: React.FC = ({ {/* Component List */}
+ {/* My Templates (Supporter) */} + {cloudConfigured && ( +
+ + + {expandedCategories.includes('custom') && ( +
+ {showSaveForm ? ( +
+ setSaveName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSaveTemplate(); }} + placeholder="Template name…" + autoFocus + className="w-full px-2.5 py-1.5 text-sm border border-gray-200 rounded-md focus:border-indigo-400 focus:ring-1 focus:ring-indigo-100 outline-none" + /> +
+ + +
+
+ ) : ( + + )} + + {/* Errors from save OR delete show regardless of form state */} + {saveError &&

{saveError}

} + + {filteredCustom.map((t) => ( + // Not a +
+ ))} + + {filteredCustom.length === 0 && !showSaveForm && ( +

+ {user && isPro + ? 'No templates yet, save your favourite curve setups.' + : 'Save & sync your own templates with the Supporter plan.'} +

+ )} +
+ )} +
+ )} + {Object.entries(categoryLabels).map(([category, { label, color }]) => { const templates = groupedTemplates[category]; if (!templates || templates.length === 0) return null; @@ -153,6 +363,16 @@ const ComponentLibrary: React.FC = ({

+ + setPendingDelete(null)} + onConfirm={confirmDeleteTemplate} + title="Delete Template" + message={`Delete the template "${pendingDelete?.name ?? ''}"? It will be removed from your library on all your devices. This can't be undone.`} + confirmText="Delete" + variant="danger" + />
); }; diff --git a/components/DiagramRenderer.tsx b/components/DiagramRenderer.tsx index 272cdba..8df75d7 100644 --- a/components/DiagramRenderer.tsx +++ b/components/DiagramRenderer.tsx @@ -30,6 +30,11 @@ const LINE_HIT_TOLERANCE = 8; const FormattedText = ({ text, x, y, className, textAnchor = "middle", dominantBaseline, style, ...props }: any) => { const parts = useMemo(() => { const tokens: { type: string; content: string }[] = []; + // Labels are required by the generation schema but not guaranteed by it: + // OpenRouter has no schema, and a diagram can also be hand-edited or + // predate a field. Rendering nothing beats taking the canvas down with + // a `length of undefined` on the whole diagram. + if (typeof text !== 'string') return tokens; let i = 0; while (i < text.length) { const char = text[i]; diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index ad1a044..d5b1934 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -20,8 +20,36 @@ import { export interface LandingPageProps { onGoHome: () => void; + onOpenPricing: () => void; + onOpenCompare: () => void; + onOpenPrivacy: () => void; + onOpenTerms: () => void; } +/** + * Footer legal link. Stays a real `` so it is crawlable, middle-clickable + * and shows its target on hover, but a plain left click routes through the SPA + * instead of reloading the whole bundle. Modifier clicks are left to the + * browser. + */ +const RouteLink: React.FC<{ href: string; onNavigate: () => void; children: React.ReactNode }> = ({ + href, + onNavigate, + children, +}) => ( + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; + e.preventDefault(); + onNavigate(); + }} + className="text-sm text-gray-500 hover:text-gray-900 transition-colors" + > + {children} + +); + // ─── Fade-in on scroll component ─── const ScrollReveal: React.FC<{ children: React.ReactNode; @@ -71,7 +99,7 @@ const ScrollReveal: React.FC<{ ); }; -const LandingPage: React.FC = ({ onGoHome }) => { +const LandingPage: React.FC = ({ onGoHome, onOpenPricing, onOpenCompare, onOpenPrivacy, onOpenTerms }) => { const [scrollY, setScrollY] = useState(0); const [heroTilt, setHeroTilt] = useState({ rotateX: 0, rotateY: 0 }); const [openSourceMouse, setOpenSourceMouse] = useState({ x: 50, y: 50 }); @@ -194,6 +222,18 @@ const LandingPage: React.FC = ({ onGoHome }) => {
+ + = ({ onGoHome }) => {
-

+

- Free & open source forever. No account required. + Everything a student needs to finish their IA is free and unlimited, forever.

@@ -396,7 +436,7 @@ const LandingPage: React.FC = ({ onGoHome }) => { Community Driven.

- IB EconGraph AI is fully open source under the MIT License. Inspect the code, + IB EconGraph AI is fully open source under the GNU AGPL v3. Inspect the code, contribute features, report bugs, or fork it for your own needs. Built by students, for students.

@@ -419,7 +459,23 @@ const LandingPage: React.FC = ({ onGoHome }) => { Star the Repo
+ + + Support the Project + +

+ Sponsorships and the{' '} + {' '} + keep this tool free for every student. Thank you. +

@@ -427,7 +483,7 @@ const LandingPage: React.FC = ({ onGoHome }) => {
Free
-
MIT
+
AGPL
License
@@ -623,7 +679,7 @@ const LandingPage: React.FC = ({ onGoHome }) => {

Whether you're preparing for Paper 1, working on your Internal Assessment, - or studying for exams — IB EconGraph AI helps you create the exact diagrams + or studying for exams, IB EconGraph AI helps you create the exact diagrams your IB Economics course demands, from microeconomics to international trade.

@@ -643,7 +699,7 @@ const LandingPage: React.FC = ({ onGoHome }) => { { icon: , title: 'Full IB Curriculum', - desc: 'Covers all IB Economics topics — micro, macro, international, and development economics.', + desc: 'Covers all the IB Economics topics: micro, macro, international and development economics.', color: 'text-amber-600 bg-amber-100', }, ].map((item, i) => ( @@ -701,21 +757,44 @@ const LandingPage: React.FC = ({ onGoHome }) => {
IB EconGraph AI
-

+

Free & open source. Built for IB Economics students and educators.

-
+
+ + + Privacy + Terms + + + Support + GitHub | - MIT License + AGPL-3.0
diff --git a/components/LegalPages.tsx b/components/LegalPages.tsx new file mode 100644 index 0000000..40c56e0 --- /dev/null +++ b/components/LegalPages.tsx @@ -0,0 +1,257 @@ +import React from 'react'; + +const SITE = 'https://ib-econgraph-ai.vercel.app'; +const REPO = 'https://github.com/sukarth/IB-EconGraph-AI'; +const CONTACT_EMAIL = 'sukarth.dev@gmail.com'; +const LAST_UPDATED = '19 July 2026'; + +/** Inline chevron used in place of a literal arrow character in nav breadcrumbs. */ +const Arrow: React.FC = () => ( + +); + +const LegalLayout: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+ +
+

{title}

+ {/* gray-500 rather than gray-400: gray-400 on white is 2.5:1, under + WCAG AA's 4.5:1 minimum for normal-size text. */} +

Last updated: {LAST_UPDATED}

+
{children}
+ +
+
+); + +const H2: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +

{children}

+); +const P: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +

{children}

+); +const LI: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
  • {children}
  • +); + +export const PrivacyPage: React.FC = () => ( + +
    +

    + IB EconGraph AI ("the Service", "we", "us") is a free, open-source diagram editor for + IB Economics students and teachers. This policy explains what data we handle and why. + The Service works fully offline in your browser without an account. The data below is + only involved if you choose to create an account or use the optional Supporter features. +

    +
    + +
    +

    What we collect

    +
      +
    • Account details. If you sign up, we store your email address and, via our + authentication provider, a securely hashed password. Google sign-in shares your email and + basic profile. You may optionally add a display name or a "supporter name".
    • +
    • Synced content (Supporter plan). If you turn on cloud sync, your diagrams, + projects, custom templates, version history and share links are stored on our servers so you + can access them across devices.
    • +
    • Hosted AI prompts (Supporter plan). When you use hosted AI generation, the + text prompt you submit is sent to Google's Gemini models to produce a diagram. We meter the + number of generations per month but do not use your prompts to train any model.
    • +
    • Billing data. Payments are processed by Polar as merchant of record. We never + receive or store your full card details. We store a Polar customer/subscription identifier and + your subscription status so we can grant Supporter access.
    • +
    • Local-only data. Diagrams you create without sync, and any AI API keys you + enter yourself (BYOK), stay in your browser's local storage and are never sent to us.
    • +
    +
    + +
    +

    How we use it

    +

    + We use this data only to provide the Service: to authenticate you, sync and back up your work, + deliver hosted AI, process your subscription, and credit supporters who opt in. We do not sell + your data, and we do not run third-party advertising trackers. +

    +
    + +
    +

    Service providers

    +

    We rely on a small number of processors, each handling only what their function needs:

    +
      +
    • Supabase: authentication and database (your account and synced content).
    • +
    • Polar: subscription billing and payment processing (merchant of record).
    • +
    • Google (Gemini models, via Vertex AI or the Gemini API): processes hosted AI prompts to generate diagrams.
    • +
    • Vercel: application hosting and content delivery.
    • +
    +
    + +
    +

    Data retention & deletion

    +

    + We keep your account data until you delete it. You can permanently delete your account and all + cloud-synced data at any time from Settings Account & Cloud Delete account, + which also cancels any active subscription. You can export a full copy of your data at any time + from Settings Import & Export. To make a request by email, contact us at + the address below. +

    +
    + +
    +

    Your rights

    +

    + Depending on where you live (for example under the EU/UK GDPR), you have rights to access, correct, + export, and delete your personal data, and to object to certain processing. The in-app export and + delete tools cover most of these directly; for anything else, email us and we'll help. +

    +
    + +
    +

    Children & students

    +

    + The Service is aimed at IB Economics students, some of whom are minors. We only collect the + minimal account data described above. If you are below the age of digital consent in your country + (for example under 16 in parts of the EU, or under 13 in the US), please use the Service with a + parent's or guardian's permission, and have them create or approve any account. If you believe a + child has given us personal data without appropriate consent, contact us and we will delete it. +

    +
    + +
    +

    Security & international transfer

    +

    + Data is transmitted over encrypted connections (HTTPS) and protected by row-level security so each + account can only access its own records. Our providers may process data in the EU and the US; + where required, they rely on appropriate safeguards for international transfers. +

    +
    + +
    +

    Changes & contact

    +

    + We may update this policy as the Service evolves, and we'll revise the "last updated" date above. + Questions or requests: email {CONTACT_EMAIL}{' '} + or open an issue on GitHub. +

    +
    +
    +); + +export const TermsPage: React.FC = () => ( + +
    +

    + These terms govern your use of IB EconGraph AI ("the Service"). By using the Service you agree to + them. If you don't agree, please don't use the Service. +

    +
    + +
    +

    The Service

    +

    + IB EconGraph AI is a diagram editor for IB Economics. The core editor is free to use, and we + intend to keep it that way: unlimited diagrams and projects, every drawing tool and template, + full-quality exports with no watermark, and AI generation using your own API key. We won't + retroactively paywall diagrams you've already made or your ability to export them. The optional + Supporter plan adds hosted conveniences (hosted AI, cloud sync, version history, + share links, synced templates). +

    +
    + +
    +

    Accounts

    +

    + You need an account only for Supporter features. Provide accurate information, keep your password + secure, and you're responsible for activity under your account. You can delete your account at any + time from Settings. +

    +
    + +
    +

    Subscriptions, billing & cancellation

    +
      +
    • The Supporter plan is $5/month or $50/year, billed through Polar, our merchant of record, + which also handles applicable taxes (e.g. VAT).
    • +
    • Subscriptions renew automatically each period until cancelled.
    • +
    • You can cancel any time via Manage billing in Settings. Access continues + until the end of the period you've already paid for, after which it ends.
    • +
    • Except where required by law (for example EU/UK withdrawal rights, handled through Polar), + payments are non-refundable. Deleting your account cancels the subscription.
    • +
    +
    + +
    +

    Hosted AI & fair use

    +

    + Hosted AI generation is included with the Supporter plan up to a monthly limit (currently 150 + generations). It's for normal, personal use in creating economics diagrams. Automated abuse, + reselling, or attempts to extract or overuse the underlying AI service may be rate-limited or + suspended. You can always switch to your own API key instead. Bring-your-own-key generation is + not metered by this app, but it stays subject to your provider's own limits, usage rules and costs. +

    +
    + +
    +

    Acceptable use

    +

    + Don't use the Service for anything unlawful, don't attempt to break its security or access other + users' data, and don't misuse the AI features. We may suspend accounts that do. +

    +
    + +
    +

    Your content & our code

    +

    + Your diagrams and projects are yours. The application's source code is open source under the GNU + Affero General Public License v3.0 (AGPL-3.0); see our repository for the full text. You grant us + only the limited permission needed to store and sync your content so we can provide the Service. +

    +
    + +
    +

    Disclaimer & liability

    +

    + The Service is provided "as is", without warranties of any kind. It's an educational tool; + AI-generated diagrams may contain mistakes, and you're responsible for checking your work. We + don't guarantee exam accuracy or results. To the fullest extent permitted by law, we are not + liable for indirect or consequential damages, and our total liability is limited to the amount you + paid us in the past 12 months. +

    +
    + +
    +

    Changes, termination & contact

    +

    + We may update these terms or the Service; material changes will be reflected in the "last updated" + date. We may suspend or end access for violations of these terms. These terms are governed by the + laws of Finland. If you are a consumer in the EU or EEA, you also keep the + protection of the mandatory consumer-law provisions of your country of residence. Questions: + email {CONTACT_EMAIL}{' '} + or open an issue on GitHub. +

    +
    +
    +); diff --git a/components/PricingPage.tsx b/components/PricingPage.tsx new file mode 100644 index 0000000..ea03e2a --- /dev/null +++ b/components/PricingPage.tsx @@ -0,0 +1,332 @@ +import React, { useState } from 'react'; +import { + BarChart2, Check, Crown, Github, Heart, ArrowRight, Sparkles, Cloud, + Link2, Layers, BookOpen, Loader2, Coffee, Star, GraduationCap, ShieldCheck, +} from 'lucide-react'; +import { useAuth } from '../services/auth'; +import { startCheckout } from '../services/billing'; +import AuthModal from './AuthModal'; + +interface PricingPageProps { + onOpenEditor: () => void; + onOpenLanding: () => void; + onOpenCompare: () => void; + onOpenSettings: () => void; +} + +const FREE_FEATURES = [ + 'Unlimited diagrams and projects', + 'Every drawing tool and all 15+ built-in templates', + 'All export formats (SVG, PNG, JPEG) at full quality, no watermark, ever', + 'Unlimited AI generation with your own free API key (BYOK)', + 'Local JSON backup & restore of everything', + 'Open source (AGPL-3.0), inspect it, fork it, self-host it', +]; + +const SUPPORTER_FEATURES: { icon: React.ReactNode; text: string }[] = [ + { icon: , text: 'Hosted AI, no API key setup, 150 generations/month included' }, + { icon: , text: 'Cloud sync across devices (school laptop and home) with version history' }, + { icon: , text: 'Shareable view-only links, send a diagram to your teacher or group partner' }, + { icon: , text: 'Custom template library, save your own curve setups, synced' }, + { icon: , text: 'Supporter badge + your name in the README (optional)' }, +]; + +const FAQ: { q: string; a: string }[] = [ + { + q: 'Will features ever move from Free to paid?', + a: 'No. That is the whole point of the guarantee: unlimited diagrams, all tools and templates, full-quality watermark-free exports, unlimited BYOK AI, and local backup stay free forever. Supporter only adds hosted conveniences that genuinely cost money to run (servers, hosted AI).', + }, + { + q: 'What happens to my data if I cancel Supporter?', + a: 'You keep everything. Your data always lives in your browser first, you can export a full JSON backup any time, and reading your synced data is never locked, only new cloud writes pause until you resubscribe.', + }, + { + q: 'Is VAT included? Can I get an invoice?', + a: 'Yes. Payments are processed by Polar as merchant of record, which handles EU VAT and provides invoices from the billing portal.', + }, + { + q: 'Is my work private?', + a: 'Yes. Locally, everything stays in your browser. With sync, data is stored under your account (row-level security). Share links contain only the diagram, never your AI chat history, and can be revoked at any time.', + }, + { + q: "I'm a teacher, can I get this for my whole class?", + a: "The free tier already covers everything a class needs for IAs. If there's genuine demand for a Classroom plan (one license, whole class gets Supporter), it will happen, open a GitHub issue to register interest.", + }, +]; + +const PricingPage: React.FC = ({ onOpenEditor, onOpenLanding, onOpenCompare, onOpenSettings }) => { + const { configured, user, isPro } = useAuth(); + const [interval, setInterval] = useState<'month' | 'year'>('month'); + const [checkoutLoading, setCheckoutLoading] = useState(false); + const [checkoutError, setCheckoutError] = useState(null); + const [authModalOpen, setAuthModalOpen] = useState(false); + + const handleSubscribe = async () => { + setCheckoutError(null); + if (!configured) { + setCheckoutError('Billing is not configured on this deployment.'); + return; + } + if (!user) { + setAuthModalOpen(true); + return; + } + if (isPro) { + onOpenSettings(); + return; + } + setCheckoutLoading(true); + const result = await startCheckout(interval); + setCheckoutLoading(false); + if (result.url) { + window.location.href = result.url; + } else { + setCheckoutError(result.error ?? 'Could not start checkout.'); + } + }; + + return ( +
    + setAuthModalOpen(false)} + title="Sign in to continue" + message="Create a free account first (it takes a few seconds). Once you're signed in, click Become a Supporter again to go to checkout." + // Come back here, not to Settings: the message above tells them + // to click Become a Supporter again, which only exists on this page. + returnTo="/pricing" + /> + + {/* Nav */} + + + {/* Hero */} +
    +
    +
    + + The guarantee +
    +

    + Everything a student needs to finish their IA is{' '} + + free and unlimited, forever. + +

    +

    + No trials, no watermarks, no export paywalls, no diagram limits. + The Supporter plan exists for hosted convenience and for people who want + to keep this project alive. +

    +
    +
    + + {/* Plans */} +
    +
    + {/* Free */} +
    +
    +
    + +

    Free

    +
    +
    + $0 + forever +
    +

    Everything you need for your IA, Paper 1, and beyond.

    +
    +
      + {FREE_FEATURES.map((feature, i) => ( +
    • +
      + +
      + {feature} +
    • + ))} +
    + +
    + + {/* Supporter */} +
    +
    +
    + +

    Supporter

    +
    +
    + {interval === 'month' ? '$5' : '$50'} + /{interval === 'month' ? 'month' : 'year'} +
    +
    + + +
    +
    +
    Everything in Free, plus:
    +
      + {SUPPORTER_FEATURES.map((feature, i) => ( +
    • +
      + {feature.icon} +
      + {feature.text} +
    • + ))} +
    + + {checkoutError && ( +
    {checkoutError}
    + )} +

    + Payments processed by Polar. +

    +
    +
    +
    + + {/* Other ways to support */} +
    +
    +

    + Other ways to support the project +

    +

    + Not into subscriptions? One-off support keeps the lights on just as well. And starring the + repo helps more students find a free tool. +

    + +
    +
    + + {/* FAQ */} +
    +
    +

    + Questions, answered honestly +

    +
    + {FAQ.map((item, i) => ( +
    +

    {item.q}

    +

    {item.a}

    +
    + ))} +
    +
    +
    + + {/* Footer */} +
    +
    + +
    + + + GitHub + + AGPL-3.0 +
    +
    +
    +
    + ); +}; + +export default PricingPage; diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 3067db1..231a2db 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -1,7 +1,8 @@ import React, { useState, useRef, useEffect } from 'react'; import { ChevronLeft, Key, Eye, EyeOff, Check, AlertTriangle, - Download, Upload, BarChart2, Trash2, ExternalLink, Cpu, RefreshCw + Download, Upload, BarChart2, Trash2, ExternalLink, Cpu, RefreshCw, + Crown, Sparkles } from 'lucide-react'; import { getApiKey as getGeminiApiKey, @@ -24,12 +25,18 @@ import { import { AIProvider, getAIProvider, setAIProvider } from '../services/aiProvider'; import { Graph, Project } from '../types'; import { ConfirmModal } from './Modal'; +import AccountSection from './AccountSection'; +import { useAuth } from '../services/auth'; +import { SyncState } from '../services/useCloudSync'; interface SettingsPageProps { onBack: () => void; graphs: Graph[]; projects: Project[]; onImportData: (data: { graphs: Graph[]; projects: Project[]; specialColors?: string[]; standardColors?: string[] }) => void; + syncState: SyncState; + onSyncNow: () => void; + onOpenPricing: () => void; } const EXPORT_VERSION = 1; @@ -51,7 +58,11 @@ const SettingsPage: React.FC = ({ graphs, projects, onImportData, + syncState, + onSyncNow, + onOpenPricing, }) => { + const { configured: cloudConfigured, user, isPro } = useAuth(); const [provider, setProviderState] = useState(() => getAIProvider()); const [apiKey, setApiKey] = useState(''); const [showKey, setShowKey] = useState(false); @@ -73,6 +84,21 @@ const SettingsPage: React.FC = ({ const [openRouterModelsError, setOpenRouterModelsError] = useState(null); const loadProviderState = (p: AIProvider) => { + if (p === 'hosted') { + // Hosted provider has no key or model selection, server-managed. + setApiKey(''); + setKeyConfigured(false); + setSelectedModel(''); + setAvailableModels([]); + setModelsFetched(false); + setModelsError(null); + setLoadingModels(false); + setOpenRouterModels([]); + setOpenRouterModelsFetched(false); + setOpenRouterModelsError(null); + setOpenRouterModelsLoading(false); + return; + } const existingKey = p === 'openrouter' ? getOpenRouterApiKey() : getGeminiApiKey(); if (existingKey) { setApiKey(existingKey); @@ -309,7 +335,7 @@ const SettingsPage: React.FC = ({ }} onConfirm={confirmImport} title="Import Backup Data" - message={`This will replace all your current data with ${pendingImportData?.graphs.length || 0} graph${(pendingImportData?.graphs.length || 0) !== 1 ? 's' : ''} and ${pendingImportData?.projects.length || 0} project${(pendingImportData?.projects.length || 0) !== 1 ? 's' : ''}. This action cannot be undone.`} + message={`This will replace all your current diagrams and projects, including any synced from your other devices, with ${pendingImportData?.graphs.length || 0} diagram${(pendingImportData?.graphs.length || 0) !== 1 ? 's' : ''} and ${pendingImportData?.projects.length || 0} project${(pendingImportData?.projects.length || 0) !== 1 ? 's' : ''} from this backup. This can't be undone.`} confirmText="Import" variant="danger" /> @@ -338,6 +364,13 @@ const SettingsPage: React.FC = ({
    + {/* Account & Cloud Section */} + + {/* API Key Section */}
    @@ -363,10 +396,49 @@ const SettingsPage: React.FC = ({ onChange={(e) => handleProviderChange(e.target.value as AIProvider)} className="w-full px-4 py-3 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50 transition-all" > - - + + + {cloudConfigured && ( + + )}
    + + {/* Hosted provider status */} + {provider === 'hosted' && ( +
    + {user && isPro ? ( +
    + + Hosted AI is active, no API key needed. Usage is shown in Account & Cloud above. +
    + ) : user ? ( +
    +
    + + Hosted AI is part of the Supporter plan ($5/month). +
    + +
    + ) : ( +
    + + Sign in above to use hosted AI, or pick a free BYOK provider. +
    + )} +

    + Prefer full control? Both BYOK providers stay free and unlimited with your own key. +

    +
    + )} + + {provider !== 'hosted' && (<> {/* Status indicator */}
    = ({ ) : ( <> - No API key configured — AI features are disabled + No API key configured, AI features are disabled )}
    @@ -467,10 +539,12 @@ const SettingsPage: React.FC = ({
    + )} {/* Model Selection Section */} + {provider !== 'hosted' && (
    @@ -663,6 +737,7 @@ const SettingsPage: React.FC = ({ )}
    + )} {/* Import/Export Section */}
    @@ -742,6 +817,33 @@ const SettingsPage: React.FC = ({ )}
    + + {/* About / source. AGPL-3.0 requires offering the source to + everyone who interacts with the app over a network. */} + ); diff --git a/components/ShareModal.tsx b/components/ShareModal.tsx new file mode 100644 index 0000000..ecc6732 --- /dev/null +++ b/components/ShareModal.tsx @@ -0,0 +1,193 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Link2, Copy, Check, Loader2, Trash2, Crown, LogIn } from 'lucide-react'; +import { Modal } from './Modal'; +import { useAuth } from '../services/auth'; +import { + createOrUpdateGraphShare, + getShareIdForGraph, + revokeShare, + shareUrl, +} from '../services/shares'; +import { Graph } from '../types'; + +interface ShareModalProps { + isOpen: boolean; + onClose: () => void; + graph: Graph | null; + onOpenSettings: () => void; + onOpenPricing: () => void; +} + +/** + * Creates/copies/revokes a view-only link for the active graph. + * Supporter feature, non-entitled users see the upgrade path instead. + */ +export const ShareModal: React.FC = ({ isOpen, onClose, graph, onOpenSettings, onOpenPricing }) => { + const { configured, user, isPro } = useAuth(); + const [shareId, setShareId] = useState(null); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + + // Keyed on graph.id, not the graph object: `activeGraph` in App.tsx is a + // useMemo over `graphs`, so its identity changes on every autosave. With the + // object in the deps, simply editing the diagram with this modal open + // re-issued the lookup query on each keystroke-debounce. + // (Same for user: the Supabase User object is replaced on every token + // refresh, which would re-run this for no reason.) + const graphId = graph?.id ?? null; + const userId = user?.id ?? null; + useEffect(() => { + // Drop any link belonging to a previously inspected graph, so a stale + // URL can never be shown for the current one. + setShareId(null); + if (!isOpen || !graphId || !userId || !isPro) return; + let cancelled = false; + setLoading(true); + setError(null); + setCopied(false); + getShareIdForGraph(graphId) + .then((id) => { if (!cancelled) setShareId(id); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [isOpen, graphId, userId, isPro]); + + const handleCreate = useCallback(async () => { + if (!graph || !user || creating) return; + setCreating(true); + setError(null); + const result = await createOrUpdateGraphShare(user.id, graph); + setCreating(false); + if (result.error) { + setError(result.error); + } else if (result.id) { + setShareId(result.id); + } + }, [graph, user, creating]); + + const handleCopy = useCallback(async () => { + if (!shareId) return; + try { + await navigator.clipboard.writeText(shareUrl(shareId)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setError('Could not copy, select the link and copy it manually.'); + } + }, [shareId]); + + const handleRevoke = useCallback(async () => { + if (!shareId) return; + const result = await revokeShare(shareId); + if (result.error) { + setError(result.error); + } else { + setShareId(null); + } + }, [shareId]); + + if (!isOpen) return null; + + return ( + + {!configured ? ( +

    + Sharing isn't available on this deployment. You can still export the + diagram as SVG/PNG and send the file. +

    + ) : !user ? ( +
    +

    + Sign in to create a view-only link you can send to your teacher or group partner. +

    + +
    + ) : !isPro ? ( +
    +
    + +
    +

    + Shareable links are part of the Supporter plan + ($5/month). Unable to support? Everything you need to finish your IA, editor, exports, AI with + your own key, still stays free forever. +

    + +
    + ) : loading ? ( +
    + +
    + ) : shareId ? ( +
    +

    + Anyone with this link can view the + diagram (never your chat history). It stays up to date as you edit and sync. +

    +
    +
    + + {shareUrl(shareId)} +
    + +
    +
    + + +
    + {error &&
    {error}
    } +
    + ) : ( +
    +

    + Create a view-only link for + {' '}{graph?.diagramData.title || 'this graph'}. + Perfect for sending to a teacher or group partner without exporting files. +

    + + {error &&
    {error}
    } +
    + )} +
    + ); +}; + +export default ShareModal; diff --git a/components/SharedViewPage.tsx b/components/SharedViewPage.tsx new file mode 100644 index 0000000..4c3d44c --- /dev/null +++ b/components/SharedViewPage.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect } from 'react'; +import { BarChart2, Loader2, AlertTriangle, ArrowRight } from 'lucide-react'; +import DiagramRenderer from './DiagramRenderer'; +import { fetchSharedPayload, SharePayload, SharedGraphEntry } from '../services/shares'; +import { DEFAULT_EDITOR_SETTINGS } from '../types'; + +interface SharedViewPageProps { + slug: string; + onGoHome: () => void; +} + +/** + * Public, read-only viewer for shared diagram/project links (/s/:slug). + * No account needed, anyone with the link can view. + */ +const SharedViewPage: React.FC = ({ slug, onGoHome }) => { + const [payload, setPayload] = useState(null); + const [status, setStatus] = useState<'loading' | 'ready' | 'notfound' | 'error'>('loading'); + const [activeIndex, setActiveIndex] = useState(0); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + let cancelled = false; + setStatus('loading'); + fetchSharedPayload(slug) + .then((data) => { + if (cancelled) return; + if (data) { + setPayload(data); + setStatus('ready'); + } else { + setStatus('notfound'); + } + }) + .catch(() => { + if (!cancelled) setStatus('error'); + }); + return () => { cancelled = true; }; + }, [slug, reloadKey]); + + const graphs: SharedGraphEntry[] = payload + ? payload.kind === 'graph' + ? [{ id: 'single', title: payload.title, caption: payload.caption, diagramData: payload.diagramData }] + : payload.graphs + : []; + const active = graphs[Math.min(activeIndex, Math.max(graphs.length - 1, 0))]; + + return ( +
    + {/* Header */} +
    +
    +
    +
    + +
    +
    +
    + {payload?.kind === 'project' ? payload.name : active?.title || 'Shared diagram'} +
    +
    Shared view-only · IB EconGraph AI
    +
    +
    + +
    +
    + +
    + {status === 'loading' && ( +
    + +
    + )} + + {status === 'notfound' && ( +
    +
    +
    + +
    +

    This link isn't available

    +

    + The share link may have been revoked, or the diagram was deleted by its owner. +

    + +
    +
    + )} + + {status === 'error' && ( +
    +
    +
    + +
    +

    Couldn't load this link

    +

    + Something went wrong reaching the server. Check your connection and try again. +

    + +
    +
    + )} + + {status === 'ready' && payload && ( + <> + {payload.kind === 'project' && graphs.length > 1 && ( + + )} + +
    + {payload.kind === 'project' && graphs.length > 1 && ( + // Phone fallback for the sidebar above, which is + // hidden below `sm`. Without it, a shared project + // only ever showed its first graph on a phone. +
    +
    + {graphs.map((g, i) => ( + + ))} +
    +
    + )} + +
    + {active ? ( +
    + + {active.caption && ( +

    {active.caption}

    + )} +
    + ) : ( +

    This project has no graphs yet.

    + )} +
    +
    + + )} +
    + +
    + Made with{' '} + + , the free, open-source economics diagram editor for IB students. +
    +
    + ); +}; + +export default SharedViewPage; diff --git a/docs/BACKEND_SETUP.md b/docs/BACKEND_SETUP.md new file mode 100644 index 0000000..bd1c070 --- /dev/null +++ b/docs/BACKEND_SETUP.md @@ -0,0 +1,278 @@ +# Backend Setup — Accounts, Cloud Sync & the Supporter Plan + +IB EconGraph AI runs **fully free and local by default**: no accounts, no server, +data in `localStorage`, AI via the user's own API key. This guide configures the +optional cloud backend that powers the **Supporter** plan: + +| Feature | Needs | +|---|---| +| Sign-in (email + password / Google) | Supabase | +| Cloud sync + version history | Supabase | +| Shareable view-only links | Supabase | +| Custom template library | Supabase | +| Hosted AI (no BYOK key) | Supabase + a server AI key (Vertex AI or Google AI Studio) | +| Subscriptions / billing | Polar | + +If any environment variable is missing, the related feature quietly disappears +from the UI — a fork with zero configuration still works perfectly. + +--- + +## 1. Supabase (auth + database) + +1. Create a project at [supabase.com](https://supabase.com) (free tier is fine). +2. In the **SQL Editor**, paste and run the entire contents of + [`supabase/schema.sql`](../supabase/schema.sql). It is idempotent — safe to + re-run after updates. +3. **Auth → Providers → Email**: keep it enabled and turn **"Confirm email" ON**. + The app uses **email + password** with one-time email verification (not magic + links, which would send an email on every login). Optionally enable **Google** + and add your OAuth client ID/secret — Google sign-in sends **no** emails, so + it's the cheapest option for users. Also turn on **leaked-password protection** + (Auth → Providers/Policies → "Prevent use of compromised passwords" / + HaveIBeenPwned) — this clears the `auth_leaked_password_protection` linter warning. +4. **Auth → URL Configuration**: + - **Site URL** → your deployment (e.g. `https://ib-econgraph-ai.vercel.app`). + - **Redirect URLs** → add every origin you sign in from, so confirmation, + password-reset and Google OAuth links return to the right page. Use a + path wildcard (`https://your-domain/**`): sign-in returns to `/settings` + normally, but to `/pricing` when it was triggered from the checkout gate, + so a `/settings`-only entry is not enough. Include your prod domain plus, + for local testing, `http://localhost:4000/**` and your dev-tunnel + `https://.devtunnels.ms/**`. If an origin isn't listed, Supabase falls + back to the Site URL and the link won't land where it should. +5. **Auth > Emails / SMTP.** Supabase's built-in mailer is capped at **2 emails + per hour** and is explicitly **not for production**. Verification and + password-reset emails go to real users, so you need a sender their inboxes will + accept. Options for a free setup with **no custom domain**: + - **Gmail SMTP (recommended free, no-domain option).** Send through your own + Gmail account. Turn on 2-Step Verification for the Google account, generate an + **App Password** (Google Account, Security, App passwords), then in Supabase + set custom SMTP to host `smtp.gmail.com`, port `465` (SSL) or `587` (TLS), + username = your Gmail address, password = the App Password, sender = the same + Gmail address. Because the mail actually leaves Google's servers, SPF/DKIM + line up and it reaches inboxes rather than spam. Gmail allows roughly **500 + recipients/day**, far more than auth emails need. Good for a small app; move + to a domain-based sender if you ever outgrow it. + - **Lean on Google sign-in** (zero emails) as the primary path, with + email+password as the fallback. This keeps email volume tiny whatever SMTP + you use. + - **A note on Resend / Brevo / Mailjet.** These are good services, but to send + to *other people* they need a **verified domain** (you add DNS records). Their + free shared senders (for example `onboarding@resend.dev`) can only email your + own account, so without a domain they're testing-only. Once you have a cheap + domain, Resend's free tier (100/day, 3k/month) is the clean upgrade from Gmail + SMTP, and it raises Supabase's initial send limit to 30/hour (adjustable). + + Custom SMTP is a Supabase setting, not a Vercel/hosting one, so it doesn't + conflict with staying on Vercel's free plan. +6. Collect the keys from **Project Settings → API Keys**: + - Project URL → `VITE_SUPABASE_URL` *and* `SUPABASE_URL` + - **Publishable key** (`sb_publishable_…`) → `VITE_SUPABASE_PUBLISHABLE_KEY`. + This is the modern replacement for the legacy `anon` key — low-privilege and + safe to ship in the client bundle. + - **Secret key** (`sb_secret_…`) → `SUPABASE_SECRET_KEY` (server-side only, + never expose). This replaces the legacy `service_role` key; it bypasses RLS + and Supabase rejects it outright if it's ever sent from a browser. + +### Security model (already encoded in schema.sql) + +- All tables have row-level security. Users can only read their own rows. + The `shares` table is **not** publicly readable — anonymous SELECT is revoked + and view-only links resolve through the `get_share(id)` security-definer RPC, + which returns just the diagram payload (never the owner id or other shares), + so the 96-bit slugs can't be bulk-enumerated. +- **Writes** to synced data require an active Supporter entitlement + (`is_pro()`); **reads are never gated**, so lapsed subscribers can always + retrieve their data. +- Billing columns on `profiles` are writable only via the secret key + (column-level grants); users can edit only display/supporter-name fields. +- AI usage metering uses atomic SQL functions callable only with the secret key. + +## 2. Hosted AI + +The server generates diagrams for supporters using **one** of three backends. +They are tried in this order and the first one configured wins, so set the +variables for exactly one. All are set on the server (Vercel > Project > +Settings > Environment Variables). + +**Option A — Vertex AI express mode.** Vertex AI was renamed *Gemini Enterprise +Agent Platform* in 2026, but the API is the same. Express mode gives you a single +API key with no service account, so it just works on serverless. Create the key +in the Google Cloud console (express mode), then set: + +```dotenv +VERTEX_API_KEY=... # Vertex express-mode API key +HOSTED_AI_MONTHLY_LIMIT=150 +HOSTED_AI_MODEL=gemini-2.5-flash +``` + +> Personal-account caveat: creating a Vertex API key requires a Google Cloud +> **organization**. The Google-managed constraint +> `iam.managed.disableServiceAccountApiKeyCreation` is enforced by default and +> can only be lifted at the org level, so a plain personal (@gmail.com) account +> with no organization cannot create one. If that's you, use Option B (a +> service-account key, which is *not* blocked on a no-org project) or Option C. + +**Option B — Vertex AI with a project (works on a personal, no-org account).** +Use a GCP project id (plus an optional location, default `global`). Locally the +server authenticates with your gcloud Application Default Credentials, so run +`gcloud auth application-default login` once. Vercel has no gcloud, so there you +must also create a service account with the *Vertex AI User* role and paste its +key JSON, as a single line, into `GOOGLE_SERVICE_ACCOUNT_JSON`: + +```dotenv +GOOGLE_CLOUD_PROJECT=your-project-id +GOOGLE_CLOUD_LOCATION=global +GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account", ...} # Vercel only +HOSTED_AI_MONTHLY_LIMIT=150 +HOSTED_AI_MODEL=gemini-2.5-flash +``` + +**Option C — Gemini Developer API (Google AI Studio).** The simplest fully-free +option. Get a key at : + +```dotenv +GEMINI_API_KEY=... # Google AI Studio key +HOSTED_AI_MONTHLY_LIMIT=150 +HOSTED_AI_MODEL=gemini-2.5-flash +``` + +Cost check: Gemini Flash costs well under $0.01 per diagram generation, so 150 +generations cost far less than the $5/month plan price. Vertex (A/B) bills +through Google Cloud; AI Studio (C) has a free tier. + +## 3. Polar (billing) + +1. Create an organization at [polar.sh](https://polar.sh) + (use [sandbox.polar.sh](https://sandbox.polar.sh) for testing with + `POLAR_SERVER=sandbox`). +2. Create **two products**, both "Software subscription": + - *EconGraph Supporter (Monthly)* — $5 / month + - *EconGraph Supporter (Yearly)* — $50 / year + Copy each product ID into `POLAR_PRODUCT_ID_MONTHLY` / `POLAR_PRODUCT_ID_YEARLY`. +3. Create an **access token** (Settings → Developers) with `checkouts:write`, + `customer_sessions:write`, `customers:read`, and `subscriptions:write` scopes + → `POLAR_ACCESS_TOKEN`. (`subscriptions:write` lets the account-deletion + endpoint cancel a user's subscription so a deleted account isn't billed.) +4. Add a **webhook** (Settings → Webhooks): + - URL: `https:///api/webhooks/polar` + - Format: RAW + - Events: all `subscription.*` events (created, active, updated, canceled, + uncanceled, revoked, past_due) + - Copy the signing secret → `POLAR_WEBHOOK_SECRET` +5. Polar acts as **merchant of record**, so EU VAT is handled for you. + +The webhook keeps `profiles.pro_status` / `pro_until` in sync. Entitlement = +`pro_until > now()`; the server grants a 1-day grace period past each billing +period end so renewals never cause flapping. (`ACTIVE_MARGIN_DAYS` in +`api/webhooks/polar.ts`.) + +## 4. Vercel environment variables — summary + +| Variable | Scope | Purpose | +|---|---|---| +| `VITE_SUPABASE_URL` | build (client) | Supabase project URL | +| `VITE_SUPABASE_PUBLISHABLE_KEY` | build (client) | Supabase publishable key (`sb_publishable_…`) | +| `SUPABASE_URL` | server | same URL, for API routes | +| `SUPABASE_SECRET_KEY` | server | Supabase secret key (`sb_secret_…`) — never expose | +| `VERTEX_API_KEY` | server | hosted AI via Vertex express mode (option A) | +| `GOOGLE_CLOUD_PROJECT` | server | hosted AI via Vertex project (option B) | +| `GOOGLE_CLOUD_LOCATION` | server | Vertex location, default `global` | +| `GOOGLE_SERVICE_ACCOUNT_JSON` | server | Vertex service-account key JSON (option B on Vercel) | +| `GEMINI_API_KEY` | server | hosted AI via Google AI Studio (option C) | +| `HOSTED_AI_MONTHLY_LIMIT` | server | default 150 | +| `HOSTED_AI_MODEL` | server | default `gemini-2.5-flash` | +| `POLAR_ACCESS_TOKEN` | server | Polar API | +| `POLAR_WEBHOOK_SECRET` | server | webhook signature verification | +| `POLAR_PRODUCT_ID_MONTHLY` | server | monthly product | +| `POLAR_PRODUCT_ID_YEARLY` | server | yearly product | +| `POLAR_SERVER` | server | `production` or `sandbox` | +| `APP_URL` | server | canonical site URL for checkout redirects | +| `ALLOWED_ORIGINS` | server | *optional*, comma-separated extra origins allowed as checkout redirect targets | + +Checkout success/cancel URLs are handed to Polar, which redirects the browser +there after payment, so they are never taken straight from the request's +`Origin`/`Host` header. An origin is accepted only if it matches `APP_URL` or an +entry in `ALLOWED_ORIGINS`; outside production (`NODE_ENV !== 'production'`), +localhost and the dev-tunnel providers listed in `api/_lib/polar.ts` are also +accepted. Anything else falls back to `APP_URL`. A self-hosted production +deployment serving more than one domain must list the extras in +`ALLOWED_ORIGINS`. + +## 5. Testing the full flow + +> **Local dev serves the API for you.** `npm run dev` (Vite) mounts the `api/*` +> functions in-process via a dev-only plugin (see `vite.config.ts`), so +> `/api/checkout`, `/api/usage`, etc. work on `http://localhost:4000` with no +> Vercel CLI needed — it reads your local `.env` for the server-side vars. For +> local checkout redirects, set `APP_URL=http://localhost:4000`. +> (`npm run dev:api` = `npx vercel dev` is an alternative that runs the real +> Vercel runtime. The CLI is deliberately *not* in `devDependencies` — it is a +> large install that most contributors never need — so `npx` fetches it on +> first use. It also needs `vercel login`/`link` and is finicky on +> Windows + Node 24.) +> +> **Webhook reachability:** the entitlement flip to Supporter is driven by the +> Polar `subscription.*` webhook, and Polar (even in sandbox) can only reach a +> **public** URL — not `localhost`. So the checkout will open and complete +> locally, but the profile won't turn Pro until the webhook hits a reachable +> `/api/webhooks/polar`. For a true end-to-end test, either deploy a Vercel +> preview and point the Polar sandbox webhook at it, or expose your local +> server with a tunnel (ngrok/cloudflared) and use that URL in Polar. + +1. Deploy (or run `npm run dev:api`) with sandbox Polar + a real Supabase project. +2. Create an account with email + password (or Google) in Settings → Account & + Cloud. With "Confirm email" on you'll get a verification link that returns to + `/settings`; locally, either use Google or confirm the user in Supabase → + Auth → Users. +3. Pricing page → Become a Supporter → complete the sandbox checkout + (test card `4242 4242 4242 4242`). +4. You are redirected to `/settings?checkout=success`; within a few seconds the + webhook flips the profile to Supporter and the UI updates. +5. Verify: cloud sync status turns active, hosted AI provider works, a share + link opens in an incognito window, and canceling in the billing portal + downgrades after the period ends. + +## 6. Updating the README supporters list + +Fetches Supporters who opted in (Settings → "Show me in the README") and +rewrites the block between the `SUPPORTERS:START/END` markers in `README.md`. + +**Automated (recommended):** the workflow `.github/workflows/update-supporters.yml` +runs it **every Monday** (and on-demand from the Actions tab) and commits any +change. Add two repository secrets under **Settings → Secrets and variables → +Actions**: `SUPABASE_URL` and `SUPABASE_SECRET_KEY`. Nothing else to run. + +**Manually**, if you prefer: + +```bash +SUPABASE_URL=... SUPABASE_SECRET_KEY=... node scripts/update-supporters.mjs +``` + +Note it lists only *current* Supporters (subscription still active) and always +reflects each person's latest chosen name, so name changes are picked up on the +next run. + +## 7. Account deletion (GDPR) + +Users can permanently delete their account and all cloud data from **Settings → +Account & Cloud → Delete account** (backed by `/api/delete-account`). It cancels +any active Polar subscription first (needs the `subscriptions:write` token scope), +then deletes the auth user — which cascades to every table via `on delete +cascade`. Local, unsynced diagrams on the user's device are untouched. + +## 8. Free-tier fit (Supabase) + +Everything here fits Supabase's free plan for a small project: 500 MB database, +1 GB storage, 5 GB egress/month, 50,000 monthly active users, unlimited API +requests. The main watch-outs: the **2 emails/hour** auth mailer (see §1.5), and +free projects **pause after 7 days of inactivity**. Vercel's Hobby plan hosts +the app + API functions for free. + +To keep a low-traffic project from pausing, this repo ships a GitHub Actions +workflow, `.github/workflows/db-keepalive.yml`, that runs every ~5 days and does +one cheap read against the database. It reuses the same `SUPABASE_URL` and +`SUPABASE_SECRET_KEY` repository secrets as the supporters workflow (Settings > +Secrets and variables > Actions), and you can also trigger it manually from the +Actions tab. If those secrets are absent it exits cleanly without failing. diff --git a/index.html b/index.html index da592c0..9a3dc15 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - IB EconGraph AI — Free AI-Powered Economics Diagram Editor + IB EconGraph AI: Free AI-Powered Economics Diagram Editor - + @@ -30,7 +30,7 @@ - + @@ -54,6 +54,23 @@ } + + + + + + +
    + +
    +${bodyHtml} +
    +
    + © ${new Date().getFullYear()} IB EconGraph AI, free & open source (AGPL-3.0). Built for IB Economics students and educators. + + All diagrams · + Pricing · + Compare · + GitHub · + Support + +
    +
    + +`; +} + +const softwareAppLd = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'IB EconGraph AI', + description: 'Free, open-source AI-powered economics diagram editor for IB students and educators.', + applicationCategory: 'EducationalApplication', + operatingSystem: 'Web', + url: SITE_URL, + offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' }, + author: { '@type': 'Person', name: 'Sukarth Acharya' }, +}; + +function renderDiagramPage(page) { + const path = `/diagrams/${page.slug}`; + const jsonLd = [ + softwareAppLd, + { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL + '/' }, + { '@type': 'ListItem', position: 2, name: 'Diagrams', item: SITE_URL + '/diagrams' }, + { '@type': 'ListItem', position: 3, name: page.navTitle, item: SITE_URL + path }, + ], + }, + { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: page.faq.map(([q, a]) => ({ + '@type': 'Question', + name: q, + acceptedAnswer: { '@type': 'Answer', text: a }, + })), + }, + ]; + + const related = page.related + .map((slug) => { + const target = DIAGRAM_PAGES.find((p) => p.slug === slug); + return target ? `${esc(target.navTitle)}` : ''; + }) + .join(''); + + const bodyHtml = ` +
    +
    HomeDiagrams › ${esc(page.navTitle)}
    +

    ${esc(page.h1)}

    +

    ${esc(page.intro[0])}

    + +

    Free forever · no account needed · no watermark · exports as SVG, PNG & JPEG

    + +
    ${renderDiagramSvg(page)}
    + +

    ${esc(page.intro[1])}

    + +

    What the ${esc(page.keyword)} shows

    +

    ${esc(page.whatItShows.text)}

    +
      + ${page.whatItShows.bullets.map(([term, def]) => `
    • ${esc(term)}: ${esc(def)}
    • `).join('\n ')} +
    + +

    How to draw it in IB EconGraph AI

    +
      + ${page.howToDraw.map((step) => `
    1. ${esc(step)}
    2. `).join('\n ')} +
    + +

    IA & exam tips

    +
    +
      + ${page.iaTips.map((tip) => `
    • ${esc(tip)}
    • `).join('\n ')} +
    +
    + +

    Frequently asked questions

    +
    + ${page.faq.map(([q, a]) => `
    ${esc(q)}

    ${esc(a)}

    `).join('\n ')} +
    + +

    Related diagram makers

    + + +
    +

    Free, unlimited, forever.

    +

    Everything a student needs to finish their IA is free: unlimited diagrams, every tool, full-quality exports with no watermark, and unlimited AI generation with your own free API key.

    + Start drawing, it's free +
    +
    `; + + return pageShell({ + title: page.title, + description: page.metaDescription, + canonicalPath: path, + jsonLd, + bodyHtml, + }); +} + +function renderHubPage() { + const jsonLd = [ + softwareAppLd, + { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL + '/' }, + { '@type': 'ListItem', position: 2, name: 'Diagrams', item: SITE_URL + '/diagrams' }, + ], + }, + ]; + + const bodyHtml = ` +
    +
    Home › Diagrams
    +

    Every IB Economics diagram, drawable in seconds

    +

    Free, exam-ready diagram makers for the whole IB Economics syllabus, micro, macro, and international trade. Generate with AI or draw by hand, then export at full quality with no watermark.

    + +
    + ${DIAGRAM_PAGES.map((p) => `
    ${esc(p.navTitle)}
    ${esc(p.h1)}
    `).join('\n ')} +
    +
    +

    Free, unlimited, forever.

    +

    Everything a student needs to finish their IA is free: unlimited diagrams, every tool, full-quality exports with no watermark, and unlimited AI generation with your own free API key.

    + Start drawing, it's free +
    +
    `; + + return pageShell({ + title: 'IB Economics Diagram Makers: Free, AI-Powered, No Watermark | IB EconGraph AI', + description: + 'Free diagram makers for every IB Economics diagram: supply & demand, monopoly, externalities, tariffs, AD-AS, PPC and more. Draw or AI-generate, export watermark-free.', + canonicalPath: '/diagrams', + jsonLd, + bodyHtml, + }); +} + +// ── SPA route shells ───────────────────────────────────────────────────────── +// /pricing, /compare, /privacy and /terms are React views, so they would be +// served index.html, whose canonical is hardcoded to "/". App.tsx rewrites that +// after hydration, but the *served* document still tells a crawler these are +// duplicates of the homepage, which is precisely what the sitemap below is +// asking it to index. Emitting a per-route copy of the built index.html, +// differing only in the metadata, makes the served HTML self-canonicalizing +// while still booting the same SPA bundle (the SPA reads the path and renders +// the right view, exactly as it does today). +// +// Keep these titles in sync with the `meta` map in App.tsx, which sets the same +// values at runtime. +const SPA_ROUTES = [ + { + file: 'pricing.html', + path: '/pricing', + title: 'Pricing · Free Forever · IB EconGraph AI', + description: + 'IB EconGraph AI is free forever: unlimited diagrams, all templates, watermark-free exports. The optional Supporter plan adds cloud sync, version history, share links and hosted AI.', + }, + { + file: 'compare.html', + path: '/compare', + title: 'How IB EconGraph AI Compares: IB Economics Diagram Tools', + description: + 'A side-by-side comparison of IB Economics diagram tools: features, pricing, exports and openness, so you can pick the one that fits how you study.', + }, + { + file: 'privacy.html', + path: '/privacy', + title: 'Privacy Policy · IB EconGraph AI', + description: + 'How IB EconGraph AI handles your data: local-first storage, what an optional account stores, and what the hosted AI receives.', + }, + { + file: 'terms.html', + path: '/terms', + title: 'Terms of Service · IB EconGraph AI', + description: + 'Terms of service for IB EconGraph AI, the free and open-source IB Economics diagram editor, including the optional Supporter plan.', + }, +]; + +function renderSpaRouteShell(indexHtml, route) { + const url = `${SITE_URL}${route.path}`; + // Each substitution is asserted: a Vite or index.html change that stopped + // one from matching would otherwise ship a page canonicalized to "/", the + // exact bug this exists to prevent, with no sign anything went wrong. + const substitutions = [ + [/[\s\S]*?<\/title>/, `<title>${esc(route.title)}`], + [/]*>/, ``], + [//, ``], + [//, ``], + [//, ``], + [//, ``], + [//, ``], + [//, ``], + ]; + let html = indexHtml; + for (const [pattern, replacement] of substitutions) { + if (!pattern.test(html)) { + console.error(`generate-seo-pages: ${route.file} — no match for ${pattern} in dist/index.html.`); + process.exit(1); + } + html = html.replace(pattern, replacement); + } + return html; +} + +function renderSitemap() { + // Only list URLs whose served HTML self-canonicalizes. /home, /editor and + // /settings are app UI that serve index.html (canonical → "/"), so listing + // them would submit homepage duplicates. The four content routes below get + // a pre-rendered shell each (see SPA_ROUTES), so their served HTML points + // at itself rather than the homepage. + const urls = [ + { loc: '/', priority: '1.0', changefreq: 'weekly' }, + { loc: '/pricing', priority: '0.9', changefreq: 'monthly' }, + { loc: '/compare', priority: '0.8', changefreq: 'monthly' }, + { loc: '/diagrams', priority: '0.9', changefreq: 'weekly' }, + ...DIAGRAM_PAGES.map((p) => ({ loc: `/diagrams/${p.slug}`, priority: '0.8', changefreq: 'monthly' })), + { loc: '/privacy', priority: '0.3', changefreq: 'yearly' }, + { loc: '/terms', priority: '0.3', changefreq: 'yearly' }, + ]; + return ` + +${urls + .map( + (u) => ` + ${SITE_URL}${u.loc} + ${BUILD_DATE} + ${u.changefreq} + ${u.priority} + `, + ) + .join('\n')} + +`; +} + +// ── emit ───────────────────────────────────────────────────────────────────── + +mkdirSync(join(DIST, 'diagrams'), { recursive: true }); + +for (const page of DIAGRAM_PAGES) { + writeFileSync(join(DIST, 'diagrams', `${page.slug}.html`), renderDiagramPage(page)); +} +writeFileSync(join(DIST, 'diagrams.html'), renderHubPage()); + +const indexHtml = readFileSync(join(DIST, 'index.html'), 'utf8'); +for (const route of SPA_ROUTES) { + writeFileSync(join(DIST, route.file), renderSpaRouteShell(indexHtml, route)); +} + +writeFileSync(join(DIST, 'sitemap.xml'), renderSitemap()); + +console.log( + `Generated ${DIAGRAM_PAGES.length} diagram pages + hub + ${SPA_ROUTES.length} route shells + sitemap.xml into dist/`, +); diff --git a/scripts/seo-content.mjs b/scripts/seo-content.mjs new file mode 100644 index 0000000..6b740c1 --- /dev/null +++ b/scripts/seo-content.mjs @@ -0,0 +1,633 @@ +// Content for the prerendered SEO landing pages (one per diagram type). +// Rendered to static HTML by generate-seo-pages.mjs at build time. +// +// Writing guidelines: every page must be genuinely useful to an IB Economics +// student on its own (not doorway-page filler), unique, and specific to the +// diagram type. Keep claims about the product truthful: free, unlimited, +// no watermark, BYOK AI free, hosted AI on the Supporter plan. + +export const SITE_URL = 'https://ib-econgraph-ai.vercel.app'; + +/** + * diagram: simple declarative spec rendered as an inline SVG. + * lines: [x1, y1, x2, y2, color, label, dashed?] in a 0–100 coordinate space + * (y up); labels are placed at the line's end. + * points: [x, y, label] + * All pages share axes labelled by `axes` ([x, y]). + */ +export const DIAGRAM_PAGES = [ + { + slug: 'supply-and-demand', + keyword: 'supply and demand diagram', + navTitle: 'Supply & Demand', + title: 'Supply and Demand Diagram Maker: Free, No Watermark | IB EconGraph AI', + metaDescription: + 'Draw exam-ready supply and demand diagrams for IB Economics in seconds, free, unlimited, no watermark. Generate with AI or drag curves by hand, then export as SVG or PNG for your IA.', + h1: 'Supply and Demand Diagram Maker', + intro: [ + 'The supply and demand diagram is the workhorse of IB Economics: almost every microeconomics answer, from market equilibrium to government intervention, starts with these two curves. Examiners expect accurately drawn, fully labelled diagrams with equilibrium price and quantity clearly marked.', + 'IB EconGraph AI lets you draw one in seconds: describe the market in plain English and let AI plot mathematically consistent curves, or drag lines onto the canvas yourself. Export at full quality with no watermark, free, forever.', + ], + whatItShows: { + text: 'A standard market diagram plots price (P) on the vertical axis and quantity (Q) on the horizontal axis:', + bullets: [ + ['Demand curve (D)', 'downward-sloping, showing the inverse relationship between price and quantity demanded (law of demand).'], + ['Supply curve (S)', 'upward-sloping, showing that producers supply more at higher prices (law of supply).'], + ['Equilibrium (E)', 'the intersection of D and S, determining market price P* and quantity Q*, usually marked with dotted lines to both axes.'], + ['Shifts vs movements', 'a change in a determinant (income, costs, tastes) shifts the whole curve to D₁/S₁; a price change causes movement along a curve.'], + ['Consumer & producer surplus', 'the triangles between the curves and the equilibrium price line, often shaded in evaluation answers.'], + ], + }, + howToDraw: [ + 'Open the editor and pick the "Supply & Demand" template from the Component Library, or type "supply and demand equilibrium for the coffee market" in the AI panel.', + 'Label both axes (Price / Quantity) and each curve, the editor supports subscripts like D₁ using underscore notation (D_1).', + 'Mark the equilibrium with an annotated point; enable dotted lines so P* and Q* project onto both axes.', + 'To show a shift, duplicate the curve, drag it left or right, and relabel (e.g. D to D₁); add arrows or a second equilibrium point E₁.', + 'Shade consumer or producer surplus with the fill tool if your answer discusses welfare, then export as SVG or PNG.', + ], + iaTips: [ + 'For an IA commentary, always draw the diagram specific to your article, label the actual good ("Market for lithium") rather than a generic "Good X".', + 'Use a full title and figure caption (e.g. "Figure 1: Market for lithium after the export ban"), the editor has a dedicated caption field.', + 'IB markschemes reward accurate labelling above artistic quality: axes, curves, equilibrium values, and the direction of any shift must all be explicit.', + ], + faq: [ + ['Is this supply and demand graph maker really free?', 'Yes. Unlimited diagrams, every drawing tool, and full-quality SVG/PNG/JPEG export with no watermark are free forever. AI generation is also free with your own Google AI Studio key.'], + ['Can the AI draw curve shifts?', 'Yes, ask for e.g. "show demand increasing for electric cars" and it plots the original curve, the shifted curve, and both equilibria with consistent intersection coordinates.'], + ['What export formats can I use in my IA?', 'SVG (vector, scales perfectly in documents), PNG, and JPEG. All at full quality with no watermark on the free plan.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 10, 90, 90, '#3b82f6', 'S'], + ], + points: [[50, 50, 'E']], + }, + related: ['price-ceilings-and-floors', 'tax-incidence', 'subsidy-diagram'], + }, + { + slug: 'monopoly-diagram', + keyword: 'monopoly diagram', + navTitle: 'Monopoly', + title: 'Monopoly Diagram Maker (MR, MC, DWL): Free IB Economics Tool', + metaDescription: + 'Create accurate IB monopoly diagrams with MR below AR, profit maximisation at MC = MR, abnormal profit and deadweight loss, free, AI-assisted, exportable with no watermark.', + h1: 'Monopoly Diagram Maker', + intro: [ + 'The monopoly diagram is one of the hardest in the IB course to draw correctly: marginal revenue must sit below the demand (AR) curve with twice the slope, output is read at MC = MR, but price is read up on the demand curve. Getting these relationships wrong costs marks instantly.', + 'IB EconGraph AI knows those rules. Ask for "monopoly with abnormal profit and deadweight loss" and it plots D, MR, MC and ATC with mathematically consistent intersections, or build it yourself from the monopoly template.', + ], + whatItShows: { + text: 'The profit-maximising monopolist diagram contains:', + bullets: [ + ['Demand / AR curve', 'downward-sloping, the monopolist is a price maker facing the whole market demand.'], + ['Marginal revenue (MR)', 'below AR, falling twice as steeply; drawn dashed in most textbooks.'], + ['Profit maximisation', 'output Qₘ where MC = MR; price Pₘ read vertically up to the demand curve.'], + ['Abnormal profit', 'the rectangle between Pₘ and ATC at Qₘ, shade it when the question asks about profits.'], + ['Deadweight loss', 'the welfare triangle between the demand curve, MC, and Qₘ, showing allocative inefficiency (P > MC).'], + ], + }, + howToDraw: [ + 'Start from the "Monopoly" template in the Component Library (D, MR and MC pre-arranged), or prompt the AI with the exact scenario you need.', + 'Find MC = MR and drop an annotated point; project the dotted line down for Qₘ and up to the demand curve for Pₘ.', + 'Add the ATC curve if your answer discusses abnormal profit, and shade the profit rectangle with the fill tool.', + 'For welfare analysis, shade the DWL triangle between Qₘ and the allocatively efficient output where P = MC.', + 'Label everything, Pₘ, Qₘ, and the competitive comparison point if you\'re contrasting with perfect competition.', + ], + iaTips: [ + 'Paper 1 part (b) questions on monopoly almost always need the DWL triangle, practice shading it cleanly.', + 'When comparing with perfect competition, add P꜀ and Q꜀ on the same diagram rather than drawing two separate ones.', + 'Natural monopoly questions need a continuously falling ATC, use the bezier curve tool to get the shape right.', + ], + faq: [ + ['Does the AI get MR below AR right?', 'Yes, the generator is instructed to keep MR below the demand curve with the correct slope relationship, and you can drag any curve to fine-tune it.'], + ['Can I shade abnormal profit and DWL on the same diagram?', 'Yes. The fill tool lets you shade any polygon; use different colours (e.g. green for profit, red for DWL) from the colour palette.'], + ['Is the export watermarked?', 'No. Full-quality SVG, PNG, and JPEG exports are free with no watermark, that is part of the free-forever guarantee.'], + ], + axes: ['Quantity (Q)', 'Price, Costs (P)'], + diagram: { + lines: [ + // D = AR is P = 100 - Q, so MR = 100 - 2Q: same price intercept, + // twice the slope. Drawn from Q=10 (MR=80) to where MR hits 10. + [10, 90, 90, 10, '#ef4444', 'D=AR'], + [10, 80, 45, 10, '#ec4899', 'MR', true], + [10, 15, 85, 88, '#3b82f6', 'MC'], + ], + // MC = MR at Q = 31.9; P_m is read off demand at that quantity. + points: [[31.9, 36.3, 'MC=MR'], [31.9, 68.1, 'P_m']], + }, + related: ['perfect-competition', 'supply-and-demand', 'negative-externalities'], + }, + { + slug: 'negative-externalities', + keyword: 'negative externality diagram', + navTitle: 'Negative Externalities', + title: 'Negative Externality Diagram Maker (MSC/MPC): Free IB Tool', + metaDescription: + 'Draw negative production and consumption externality diagrams with MSC, MPC, welfare loss triangles and corrective taxes, free, exam-ready, no watermark. Built for IB Economics.', + h1: 'Negative Externality Diagram Maker', + intro: [ + 'Externality diagrams dominate IB market-failure questions and real-world IA commentaries, carbon taxes, congestion charges, sugar levies. The examiner wants to see marginal social cost diverging from marginal private cost, the welfare loss triangle pointing at the socially optimal output, and any corrective policy drawn in.', + 'With IB EconGraph AI you can generate a complete negative production externality diagram from one sentence, then adjust the divergence, shade the welfare loss, and add a tax shift, all with exact, consistent intersection points.', + ], + whatItShows: { + text: 'A negative production externality diagram (e.g. a polluting factory) shows:', + bullets: [ + ['MPC curve', 'the private supply curve, costs the producer actually pays.'], + ['MSC curve', 'above MPC; the vertical gap is the external cost imposed on third parties.'], + ['Market equilibrium (Q₁)', 'where MPC meets demand (MPB), the free-market outcome with overproduction.'], + ['Social optimum (Q*)', 'where MSC meets MSB, the allocatively efficient output.'], + ['Welfare loss', 'the triangle between MSC and MPB from Q* to Q₁, showing the deadweight loss of overproduction.'], + ], + }, + howToDraw: [ + 'Prompt the AI with e.g. "negative production externality from a coal plant with welfare loss shaded", or draw MPC first and duplicate it upward for MSC.', + 'Keep MSC parallel to MPC (a constant marginal external cost) unless your analysis argues the externality grows with output.', + 'Mark both quantities: the market output Q₁ (D = MPC) and the social optimum Q* (D = MSC), with dotted lines to the axes.', + 'Shade the welfare loss triangle between the two quantities using the fill tool.', + 'For policy evaluation, shift MPC up towards MSC to show a Pigouvian tax internalising the externality.', + ], + iaTips: [ + 'Most IA market-failure commentaries use this exact diagram, customise the labels to your article ("MSC of plastic production") to hit the "application" criterion.', + 'Distinguish production vs consumption externalities: consumption ones diverge MPB/MSB on the demand side instead.', + 'When evaluating a tax, note on the diagram whether it fully closes the MPC–MSC gap; partial internalisation is a strong evaluation point.', + ], + faq: [ + ['Can it draw consumption externalities too?', 'Yes, ask for a negative consumption externality (e.g. cigarettes) and it diverges MPB below MSB instead, with the welfare loss in the right place.'], + ['How do I show a corrective (Pigouvian) tax?', 'Duplicate the MPC curve and shift it up by the tax; the new equilibrium moves toward the social optimum. The tax-incidence template also helps here.'], + ['Is this suitable for my IA?', 'Yes, export vector SVGs that stay sharp at any size in your commentary, with your article-specific labels and figure caption.'], + ], + axes: ['Quantity (Q)', 'Costs / Benefits (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'MPB'], + [10, 10, 90, 90, '#3b82f6', 'MPC'], + // "Keep MSC parallel to MPC" per the howToDraw steps above: same + // slope, shifted up by a constant marginal external cost of 20. + [10, 30, 75, 95, '#648d49', 'MSC'], + ], + // Q_1 is MPC = MPB; Q* is MSC = MPB (the social optimum). + points: [[50, 50, 'Q_1'], [40, 60, 'Q^*']], + }, + related: ['positive-externalities', 'tax-incidence', 'subsidy-diagram'], + }, + { + slug: 'positive-externalities', + keyword: 'positive externality diagram', + navTitle: 'Positive Externalities', + title: 'Positive Externality Diagram Maker (MSB/MPB): Free IB Tool', + metaDescription: + 'Create positive consumption and production externality diagrams with MSB above MPB, underconsumption, welfare loss and subsidy corrections, free and exam-ready for IB Economics.', + h1: 'Positive Externality Diagram Maker', + intro: [ + 'Vaccinations, education, public transport, positive externality diagrams appear across IB Paper 1 and endless IA articles. The logic mirrors negative externalities but flipped: marginal social benefit sits above marginal private benefit, the market underconsumes, and government subsidies push output toward the social optimum.', + 'Generate the whole diagram with AI or assemble it from templates, with the welfare loss triangle and subsidy shift drawn precisely where they belong.', + ], + whatItShows: { + text: 'A positive consumption externality diagram (e.g. vaccination) shows:', + bullets: [ + ['MPB curve', 'the market demand curve, benefits captured by the individual consumer.'], + ['MSB curve', 'above MPB; the gap is the external benefit enjoyed by third parties (herd immunity, a more educated workforce).'], + ['Market equilibrium (Q₁)', 'where MPB meets supply (MSC), the free market underconsumes.'], + ['Social optimum (Q*)', 'where MSB meets MSC, at a higher quantity than the market delivers.'], + ['Welfare loss', 'the triangle between MSB and MSC from Q₁ to Q*, representing the forgone net benefit.'], + ], + }, + howToDraw: [ + 'Ask the AI for "positive consumption externality of vaccines with welfare loss" or start with a supply-and-demand template and add a second, higher demand curve labelled MSB.', + 'Mark Q₁ at MPB = MSC and Q* at MSB = MSC with dotted projection lines.', + 'Shade the welfare-loss triangle between the two quantities.', + 'To show a subsidy, shift the supply curve down (or MPB up for demand-side policies like advertising) and mark the new equilibrium.', + 'Add a caption tying the diagram to the specific merit good you\'re analysing.', + ], + iaTips: [ + 'State explicitly on the diagram which curves diverge, the IB rewards "MSB > MPB at every quantity" style annotations.', + 'Pair the diagram with the subsidy diagram when your article covers government support for merit goods.', + 'Evaluation gold: does the subsidy close the whole MPB–MSB gap? Draw a partial shift and discuss.', + ], + faq: [ + ['What is the difference between production and consumption positive externalities?', 'Production ones (e.g. R&D spillovers) diverge the cost curves (MSC below MPC); consumption ones (e.g. education) diverge the benefit curves (MSB above MPB). The AI handles both if you name the case.'], + ['Can I show government subsidies on the same diagram?', 'Yes, duplicate and shift the supply curve downward by the subsidy, then mark the new quantity against Q*.'], + ['Do I need an account?', 'No. The editor, templates, AI with your own key, and full-quality exports all work without signing in.'], + ], + axes: ['Quantity (Q)', 'Costs / Benefits (P)'], + diagram: { + lines: [ + [10, 80, 80, 10, '#ef4444', 'MPB'], + // MSB parallel to MPB ("MSB > MPB at every quantity"), shifted + // up by a constant marginal external benefit of 20. + [20, 90, 90, 20, '#648d49', 'MSB'], + [10, 10, 90, 90, '#3b82f6', 'MSC'], + ], + // Q_1 is MPB = MSC (the market underconsumes); Q* is MSB = MSC. + points: [[45, 45, 'Q_1'], [55, 55, 'Q^*']], + }, + related: ['negative-externalities', 'subsidy-diagram', 'supply-and-demand'], + }, + { + slug: 'price-ceilings-and-floors', + keyword: 'price ceiling and price floor diagram', + navTitle: 'Price Controls', + title: 'Price Ceiling & Price Floor Diagram Maker: Free IB Tool', + metaDescription: + 'Draw price ceiling (maximum price) and price floor (minimum price) diagrams with shortages, surpluses and welfare effects, free, unlimited, watermark-free. Made for IB Economics.', + h1: 'Price Ceiling & Price Floor Diagram Maker', + intro: [ + 'Rent controls, food price caps, minimum wages, agricultural price supports, price control diagrams turn up in every IB paper and countless IA commentaries. The key skill is placing the controlled price on the correct side of equilibrium and reading off the resulting shortage or surplus.', + 'IB EconGraph AI draws the control line, marks Qd and Qs at the controlled price, and labels the shortage or surplus gap for you, or gives you a clean canvas to construct it manually.', + ], + whatItShows: { + text: 'Price control diagrams start from ordinary supply and demand, then add a horizontal price line:', + bullets: [ + ['Price ceiling (maximum price)', 'set below equilibrium, e.g. rent control. Quantity demanded exceeds quantity supplied, creating a shortage (excess demand).'], + ['Price floor (minimum price)', 'set above equilibrium, e.g. minimum wage, farm supports. Quantity supplied exceeds quantity demanded, creating a surplus (excess supply).'], + ['Qd and Qs', 'read where the control line crosses each curve; the horizontal gap between them is the shortage/surplus, label it explicitly.'], + ['Welfare effects', 'shade the deadweight loss and the transfers between consumers and producers for evaluation answers.'], + ], + }, + howToDraw: [ + 'Generate "price ceiling below equilibrium in the rental market showing the shortage" with AI, or add a horizontal line to the supply-and-demand template.', + 'Place the ceiling below (floor above) the equilibrium, the most common student error is putting it on the wrong side, where it has no effect.', + 'Drop annotated points where the price line crosses D and S; label Qd and Qs on the axis.', + 'Draw a labelled bracket or arrow for the shortage/surplus gap using the line and text tools.', + 'Shade the DWL triangle if the question asks about welfare or efficiency.', + ], + iaTips: [ + 'A non-binding control (ceiling above equilibrium) is a legitimate evaluation point, you can draw both cases side by side in one project.', + 'For minimum wage articles, relabel the axes (Wage rate / Quantity of labour), double-click any label to edit it.', + 'Discussing black markets? Mark the price consumers would pay for the restricted quantity Qs up on the demand curve.', + ], + faq: [ + ['Which side of equilibrium does a price ceiling go?', 'A binding price ceiling sits below equilibrium (it caps the price), creating a shortage. A binding floor sits above, creating a surplus. The AI places them correctly from your description.'], + ['Can I show both a ceiling and a floor?', 'Yes, projects let you keep multiple related graphs together, or you can place both lines on one canvas for a comparison diagram.'], + ['Can I label the shortage gap?', 'Yes, use the text label tool for "shortage = Qd − Qs" and the line tool for the bracket arrows.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 10, 90, 90, '#3b82f6', 'S'], + [10, 35, 90, 35, '#f59e0b', 'P_max'], + ], + points: [[35, 35, 'Q_s'], [65, 35, 'Q_d']], + }, + related: ['supply-and-demand', 'tax-incidence', 'subsidy-diagram'], + }, + { + slug: 'tariff-diagram', + keyword: 'tariff diagram', + navTitle: 'Tariffs & Quotas', + title: 'Tariff Diagram Maker (World Price, Welfare Loss): Free IB Tool', + metaDescription: + 'Draw IB international trade tariff diagrams with world supply, domestic supply, tariff revenue and the two deadweight loss triangles, free, precise, watermark-free exports.', + h1: 'Tariff Diagram Maker', + intro: [ + 'The tariff diagram is the most detail-dense diagram in the IB course: domestic supply and demand, a horizontal world supply line, a raised world-supply-plus-tariff line, and up to six labelled quantities with revenue rectangles and two welfare-loss triangles. Drawing it by hand under time pressure is brutal.', + 'IB EconGraph AI generates the full structure with consistent geometry, and the shading tools make the revenue rectangle and DWL triangles quick to add and easy to distinguish.', + ], + whatItShows: { + text: 'The small-country tariff diagram shows:', + bullets: [ + ['Domestic S and D', 'the home market curves determining the autarky equilibrium.'], + ['World supply (Sw)', 'a horizontal line at the world price Pw, the country imports the gap between Qd and Qs at that price.'], + ['Sw + tariff', 'a parallel horizontal line at Pw + t; imports shrink as domestic output expands and consumption contracts.'], + ['Government revenue', 'the rectangle: tariff × post-tariff import quantity.'], + ['Welfare losses', 'two triangles, the production inefficiency (higher-cost domestic output) and the consumption loss (forgone consumer surplus).'], + ], + }, + howToDraw: [ + 'Prompt: "tariff diagram for a small country importing steel, show government revenue and both deadweight loss triangles".', + 'Check the four quantities on the x-axis (Qs, Qs\', Qd\', Qd) are in the right order and labelled.', + 'Shade the revenue rectangle between the two horizontal lines and the post-tariff import quantities.', + 'Shade the two DWL triangles either side of the revenue rectangle in a different colour.', + 'Add a caption naming the good and the tariff, and export as SVG for your document.', + ], + iaTips: [ + 'Trade-war and protectionism articles are IA staples, this diagram plus a stakeholder analysis (consumers, producers, government, foreign exporters) is a complete commentary skeleton.', + 'A quota uses the same structure but with no revenue rectangle for the government (the quota rent may go to foreign producers), a strong evaluation contrast.', + 'Keep colours consistent: one colour for welfare losses, another for revenue, so the examiner can read it at a glance.', + ], + faq: [ + ['Does it handle quota diagrams too?', 'Yes, describe a quota and the AI draws the restricted-imports structure; or adapt the tariff diagram manually by replacing the tariff line.'], + ['Can I label all six quantities?', 'Yes, annotated points project dotted lines onto the axes, and every label supports subscripts (Q_1, Q_2 …).'], + ['Why are there two deadweight loss triangles?', 'One is the production inefficiency (domestic firms produce units that the world could supply more cheaply); the other is lost consumer surplus from reduced consumption. The page diagram shows both positions.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 10, 90, 90, '#3b82f6', 'S'], + [10, 30, 90, 30, '#64748b', 'S_w'], + [10, 45, 90, 45, '#f59e0b', 'S_w+t'], + ], + points: [], + }, + related: ['exchange-rate-diagram', 'supply-and-demand', 'tax-incidence'], + }, + { + slug: 'ad-as-diagram', + keyword: 'AD-AS diagram', + navTitle: 'AD–AS Model', + title: 'AD-AS Diagram Maker (Keynesian & Monetarist): Free IB Tool', + metaDescription: + 'Draw AD-AS diagrams for IB macro, monetarist/new-classical LRAS, Keynesian AS, demand-side and supply-side shocks, output gaps, free with watermark-free exports.', + h1: 'AD–AS Diagram Maker', + intro: [ + 'Aggregate demand–aggregate supply diagrams carry the whole IB macroeconomics syllabus: inflation, unemployment, growth, and every fiscal or monetary policy question. You need both versions, the monetarist/new-classical model with a vertical LRAS, and the Keynesian AS curve with its flat, curved, and vertical sections.', + 'IB EconGraph AI draws both. The bezier curve tool produces a clean Keynesian AS shape that\'s notoriously hard to sketch by hand, and the AI understands prompts like "deflationary gap in the Keynesian model".', + ], + whatItShows: { + text: 'The AD–AS framework plots average price level against real GDP:', + bullets: [ + ['AD curve', 'downward-sloping: C + I + G + (X − M) at each price level.'], + ['SRAS', 'upward-sloping short-run aggregate supply based on sticky input costs.'], + ['LRAS (monetarist)', 'vertical at potential output Yp, output returns there in the long run.'], + ['Keynesian AS', 'flat at low output (spare capacity), curving upward, vertical at full capacity, equilibria below Yp can persist.'], + ['Output gaps', 'deflationary (recessionary) gaps left of Yp; inflationary gaps to the right.'], + ], + }, + howToDraw: [ + 'Tell the AI which school you need: "monetarist AD-AS with a short-run inflationary gap" vs "Keynesian AS with equilibrium below full employment".', + 'For the Keynesian curve, use a bezier curve: start flat, add a control point to bend it up into the vertical section.', + 'Mark Yp with a vertical dashed line and label the gap between Y₁ and Yp explicitly.', + 'Show policy responses by shifting AD (fiscal/monetary) or SRAS/LRAS (supply-side) and adding the new equilibrium.', + 'Relabel axes as "Average price level" and "Real GDP (Y)", double-click any label to edit.', + ], + iaTips: [ + 'Macro IA commentaries score well when the diagram shows the specific gap from your article (e.g. "Japan\'s deflationary gap") rather than a generic model.', + 'Paper 1: choose the model that matches your argument, using the Keynesian AS to discuss persistent unemployment is a classic top-band move.', + 'Always label the price level change (PL₁ to PL₂) as well as output, half the marks are on the vertical axis.', + ], + faq: [ + ['Can it draw the Keynesian AS curve shape?', 'Yes, the AI produces the three-section shape with a bezier curve, and you can drag the control points to adjust the curvature precisely.'], + ['How do I show stagflation?', 'Shift SRAS left: the new equilibrium has a higher price level and lower real output. Prompt the AI with "stagflation from an oil price shock".'], + ['Does it work for exchange-rate or Phillips-curve style axes?', 'Axes and labels are fully editable, so any two-axis macro diagram is drawable manually even when there is no dedicated template.'], + ], + axes: ['Real GDP (Y)', 'Price level'], + diagram: { + lines: [ + [10, 80, 80, 15, '#ef4444', 'AD'], + [15, 12, 88, 85, '#3b82f6', 'SRAS'], + [65, 5, 65, 95, '#64748b', 'LRAS'], + ], + // Short-run equilibrium: AD meets SRAS. + points: [[47.9, 44.9, 'Y_1']], + }, + related: ['exchange-rate-diagram', 'ppc-diagram', 'supply-and-demand'], + }, + { + slug: 'perfect-competition', + keyword: 'perfect competition diagram', + navTitle: 'Perfect Competition', + title: 'Perfect Competition Diagrams (Firm & Industry): Free IB Tool', + metaDescription: + 'Draw side-by-side industry and firm diagrams for perfect competition, short-run profit/loss and long-run equilibrium at minimum ATC, free IB Economics diagram maker, no watermark.', + h1: 'Perfect Competition Diagram Maker', + intro: [ + 'Perfect competition answers usually need two linked diagrams: the industry (market supply and demand setting price) and the individual firm (a horizontal P = AR = MR line against MC and ATC). Keeping the price line at exactly the same height across both panels is what examiners look for first.', + 'With IB EconGraph AI you can generate each panel and keep them in one project, using the horizontal-line template for the firm\'s demand curve and precise point snapping for the tangency conditions.', + ], + whatItShows: { + text: 'The two-panel perfect competition model shows:', + bullets: [ + ['Industry panel', 'market S and D determine the equilibrium price P*.'], + ['Firm panel', 'the firm takes P* as given, a horizontal line labelled P = AR = MR.'], + ['Profit maximisation', 'output where MC cuts MR from below.'], + ['Short-run abnormal profit/loss', 'the rectangle between price and ATC at the chosen output.'], + ['Long-run equilibrium', 'entry/exit shifts industry supply until P = minimum ATC and firms earn normal profit only.'], + ], + }, + howToDraw: [ + 'Create one graph for the industry (supply & demand template) and one for the firm within the same project.', + 'In the firm panel, add a horizontal "Price Line" from the Component Library and label it P = AR = MR at the industry price.', + 'Add MC and ATC bezier curves; profit-maximising output is where MC crosses the price line.', + 'Shade the profit or loss rectangle between the price line and ATC.', + 'For the long run, drag ATC until its minimum is tangent to the price line, snapping makes the tangency exact.', + ], + iaTips: [ + 'Draw the two panels with identical vertical scales so the shared price line reads clearly.', + 'Short-run loss diagrams (P below ATC but above AVC) are a common discriminator question, keep an AVC curve handy in a saved template.', + 'In "evaluate whether perfect competition is efficient" essays, mark both allocative (P = MC) and productive (min ATC) efficiency points on the firm diagram.', + ], + faq: [ + ['Can I draw the firm and industry side by side?', 'Each graph is one canvas, but projects keep the two panels together, and consistent export sizes make them easy to place side by side in a document.'], + ['How do I make ATC tangent to the price line?', 'Use point snapping, drag the ATC minimum onto the price line and the editor snaps the tangency point precisely.'], + ['Does the AI know P = AR = MR?', 'Yes, asking for "perfectly competitive firm in long-run equilibrium" produces the horizontal price line tangent to minimum ATC.'], + ], + axes: ['Quantity (Q)', 'Price, Costs (P)'], + diagram: { + lines: [ + [10, 55, 90, 55, '#f59e0b', 'P=AR=MR'], + ], + curves: [ + [10, 60, 40, 15, 90, 90, '#22c55e', 'MC'], + [10, 85, 50, 40, 90, 80, '#8b5cf6', 'ATC'], + ], + // Profit-maximising output: where the rising branch of MC cuts the + // price line from below. + points: [[60.9, 55, 'Q^*']], + }, + related: ['monopoly-diagram', 'supply-and-demand', 'ppc-diagram'], + }, + { + slug: 'ppc-diagram', + keyword: 'PPC diagram', + navTitle: 'PPC / PPF', + title: 'PPC Diagram Maker (Production Possibilities Curve): Free IB Tool', + metaDescription: + 'Draw production possibilities curves for IB Economics, opportunity cost, scarcity, actual vs potential growth, efficiency points, free PPC/PPF diagram maker with clean exports.', + h1: 'PPC / PPF Diagram Maker', + intro: [ + 'The production possibilities curve is the first diagram in the IB course and a favourite for short Paper 1 questions: scarcity, choice, opportunity cost, and the difference between actual and potential growth all live on this one curve.', + 'IB EconGraph AI\'s bezier tool draws the classic concave-to-origin bow shape smoothly, with labelled points inside, on, and outside the frontier, plus shifted curves for economic growth.', + ], + whatItShows: { + text: 'The PPC plots the maximum combinations of two goods an economy can produce:', + bullets: [ + ['The frontier', 'concave to the origin because resources are not equally suited to both goods (increasing opportunity cost).'], + ['Points on the curve', 'productive efficiency, all resources fully employed.'], + ['Points inside', 'unemployment or inefficiency (e.g. a recession).'], + ['Points outside', 'currently unattainable, reachable only through growth.'], + ['Outward shifts', 'potential growth from more/better resources or technology; movements from inside toward the curve are actual growth.'], + ], + }, + howToDraw: [ + 'Draw a bezier curve from the y-axis to the x-axis and drag the control point outward for the concave bow shape.', + 'Label the axes with your two goods (e.g. "Capital goods" and "Consumer goods").', + 'Add annotated points: A and B on the curve, C inside (unemployment), D outside (unattainable).', + 'For growth questions, duplicate the curve and drag it outward, label PPC₁ and PPC₂.', + 'A straight-line PPC (constant opportunity cost) is just the line tool, useful for comparative advantage questions.', + ], + iaTips: [ + 'Use arrows between labelled points to show the story: C to A is actual growth, curve shift is potential growth.', + 'For opportunity cost questions, mark the movement along the curve and annotate how much of one good is given up.', + 'Asymmetric shifts (pivot on one axis) show growth biased toward one sector, a subtle detail that impresses examiners.', + ], + faq: [ + ['Can I draw both straight and curved PPCs?', 'Yes, the line tool gives constant opportunity cost, the bezier tool gives the standard concave frontier.'], + ['How do I show economic growth?', 'Duplicate the curve and drag it outward (or ask the AI for "PPC with outward shift showing potential growth").'], + ['Is this free for classroom use?', 'Completely, teachers and students can use everything without accounts or licences, and the project is open source under the AGPL-3.0.'], + ], + axes: ['Consumer goods', 'Capital goods'], + diagram: { + curves: [ + [10, 85, 60, 75, 85, 10, '#3b82f6', 'PPC'], + ], + points: [[45, 68, 'A'], [30, 40, 'B']], + }, + related: ['ad-as-diagram', 'supply-and-demand', 'perfect-competition'], + }, + { + slug: 'tax-incidence', + keyword: 'tax incidence diagram', + navTitle: 'Indirect Taxes', + title: 'Indirect Tax & Tax Incidence Diagram Maker: Free IB Tool', + metaDescription: + 'Draw specific and ad valorem tax diagrams with consumer/producer incidence, government revenue and deadweight loss, free IB Economics tool with exact intersections and clean exports.', + h1: 'Indirect Tax & Tax Incidence Diagram Maker', + intro: [ + 'Indirect tax diagrams demand precision: the supply curve shifts up by exactly the tax, the new equilibrium splits the burden between consumers and producers, and the revenue rectangle plus DWL triangle must sit in exactly the right cells. Elasticity determines who pays more, the analytical heart of the question.', + 'IB EconGraph AI keeps the geometry consistent (the vertical gap between S and S+tax stays equal to the tax) and the shading tools make incidence areas unambiguous.', + ], + whatItShows: { + text: 'A specific (per-unit) tax diagram shows:', + bullets: [ + ['S and S + tax', 'the supply curve shifts vertically upward by the tax per unit (parallel for a specific tax, diverging for ad valorem).'], + ['New equilibrium', 'higher consumer price Pc, lower quantity Qt; producers receive Pp = Pc − tax.'], + ['Consumer incidence', 'the rectangle between the original price P* and Pc across Qt.'], + ['Producer incidence', 'the rectangle between P* and Pp across Qt.'], + ['Government revenue and DWL', 'revenue = tax × Qt (both incidence rectangles combined); the welfare-loss triangle sits between Qt and Q*.'], + ], + }, + howToDraw: [ + 'Use the "Tax Incidence" template, or prompt: "specific tax on cigarettes showing incidence on consumers and producers".', + 'Verify the vertical distance between S and S+tax equals the tax everywhere, drag with snapping if you adjust manually.', + 'Mark P*, Pc, and Pp with dotted lines; label Qt and Q* on the quantity axis.', + 'Shade consumer incidence and producer incidence in different colours, then the DWL triangle.', + 'For elasticity analysis, flatten or steepen the demand curve and watch the incidence split change, great for screenshots of both cases.', + ], + iaTips: [ + 'Sugar taxes, fuel duties, and tobacco excises are perennial IA topics, this diagram plus elasticity commentary is the expected core.', + 'PED vs PES rule: the more inelastic side bears more of the tax. Draw two versions to demonstrate it rather than just asserting it.', + 'Ad valorem taxes pivot the supply curve rather than shifting it in parallel, mention and draw the difference for top-band analysis.', + ], + faq: [ + ['Can it draw ad valorem taxes?', 'Yes, ask for an ad valorem (percentage) tax and the shifted supply curve diverges from the original instead of staying parallel.'], + ['How is a subsidy different?', 'A subsidy shifts supply down by the subsidy per unit, see the dedicated subsidy diagram page for the mirrored analysis.'], + ['Can I show government revenue?', 'Yes, shade the rectangle (tax × new quantity) with the fill tool; split it into the consumer and producer portions with two colours.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 10, 90, 90, '#3b82f6', 'S'], + [10, 30, 70, 90, '#3b82f6', 'S+tax', true], + ], + points: [[50, 50, 'E'], [40, 60, 'E_1']], + }, + related: ['subsidy-diagram', 'negative-externalities', 'price-ceilings-and-floors'], + }, + { + slug: 'subsidy-diagram', + keyword: 'subsidy diagram', + navTitle: 'Subsidies', + title: 'Subsidy Diagram Maker (IB Economics): Free, No Watermark', + metaDescription: + 'Draw subsidy diagrams with the supply shift, price fall, government cost rectangle and welfare analysis, free IB Economics diagram generator with AI assistance.', + h1: 'Subsidy Diagram Maker', + intro: [ + 'Subsidy diagrams mirror tax diagrams: supply shifts down by the per-unit subsidy, consumers pay less, producers receive more, and the government cost rectangle spans the entire subsidy times the new quantity. IB questions love asking who gains more, and the answer again comes down to relative elasticities.', + 'Generate the complete diagram from a one-line prompt, or shift a duplicated supply curve down with drag-and-snap precision.', + ], + whatItShows: { + text: 'A per-unit subsidy diagram shows:', + bullets: [ + ['S and S − subsidy', 'the supply curve shifts vertically down by the subsidy per unit.'], + ['New equilibrium', 'quantity rises to Qs; consumers pay the lower Pc while producers receive Pp = Pc + subsidy.'], + ['Government cost', 'the rectangle subsidy × Qs, usually the largest area on the diagram.'], + ['Consumer and producer gains', 'split of the subsidy benefit determined by relative elasticities.'], + ['Welfare loss', 'the small triangle beyond Q* where the marginal cost of extra output exceeds its marginal benefit.'], + ], + }, + howToDraw: [ + 'Prompt the AI with "subsidy for solar panels showing government cost and the price received by producers".', + 'Keep the vertical gap between the two supply curves constant, it equals the subsidy per unit.', + 'Mark three prices: original P*, consumer price Pc, and producer price Pp, all with dotted lines.', + 'Shade the government cost rectangle between Pc and Pp across the new quantity Qs.', + 'For welfare evaluation, shade the DWL triangle to the right of the original equilibrium.', + ], + iaTips: [ + 'Renewable energy and agricultural subsidy articles are IA classics, pair this diagram with an opportunity-cost evaluation of the government spending.', + 'Show explicitly that Pp − Pc equals the subsidy, annotating that vertical distance earns analysis marks.', + 'For merit goods, combine with the positive externality diagram: the subsidy is the policy that closes the MPB–MSB gap.', + ], + faq: [ + ['Which direction does supply shift for a subsidy?', 'Down (right) by the subsidy per unit, production is cheaper at every output level. The AI handles the geometry automatically.'], + ['How do I show who benefits more?', 'Compare the consumer gain (P* − Pc) with the producer gain (Pp − P*): the more inelastic side captures more. Draw steep vs flat demand versions to demonstrate.'], + ['Can I export this for my IA at high quality?', 'Yes, SVG, PNG, and JPEG exports are full quality and watermark-free, free forever.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 25, 90, 95, '#3b82f6', 'S'], + // A per-unit subsidy shifts S down by a constant amount, so + // S-sub must be parallel to S (slope 0.875, gap of 15). + [10, 10, 90, 80, '#22c55e', 'S-sub', true], + ], + // E is S = D; E_1 is S-sub = D. + points: [[44.7, 55.3, 'E'], [52.7, 47.3, 'E_1']], + }, + related: ['tax-incidence', 'positive-externalities', 'price-ceilings-and-floors'], + }, + { + slug: 'exchange-rate-diagram', + keyword: 'exchange rate diagram', + navTitle: 'Exchange Rates', + title: 'Exchange Rate Diagram Maker (Currency S&D): Free IB Tool', + metaDescription: + 'Draw floating exchange rate diagrams, currency supply and demand, appreciation and depreciation shifts, central bank intervention, free IB Economics diagram maker.', + h1: 'Exchange Rate Diagram Maker', + intro: [ + 'Exchange rate diagrams apply supply and demand to a currency market: the price axis becomes the exchange rate (e.g. USD per EUR) and the quantity axis the quantity of currency traded. Appreciations and depreciations are just demand and supply shifts, but mislabelling the axes is the classic way to lose easy marks.', + 'IB EconGraph AI relabels everything for a currency market from a single prompt and shifts the right curve for your scenario, whether it\'s rising interest rates, import demand, or central bank intervention.', + ], + whatItShows: { + text: 'A floating exchange rate diagram for, say, the euro shows:', + bullets: [ + ['Demand for EUR', 'from foreigners buying eurozone exports, assets, or travelling there, downward-sloping against the exchange rate.'], + ['Supply of EUR', 'from eurozone residents buying imports or investing abroad, upward-sloping.'], + ['Equilibrium exchange rate', 'where the curves cross, e.g. 1.10 USD/EUR.'], + ['Appreciation', 'demand shifts right (or supply left) to higher exchange rate.'], + ['Depreciation', 'demand shifts left (or supply right) to lower exchange rate.'], + ], + }, + howToDraw: [ + 'Prompt: "market for the British pound after an interest rate rise, showing appreciation", the AI labels axes as $ per £ automatically.', + 'Or start from the supply-and-demand template and double-click the axis labels to change them to "Exchange rate (USD/EUR)" and "Quantity of EUR".', + 'Shift the appropriate curve and mark both equilibria (e₁ to e₂) with dotted lines.', + 'Add an arrow annotation showing the appreciation/depreciation direction.', + 'For managed rates, add a horizontal intervention line and discuss reserves in your commentary.', + ], + iaTips: [ + 'Currency articles pair this diagram with the AD-AS model (a depreciation boosting net exports shifts AD right), keep both graphs in one project.', + 'Always state the exchange rate as a ratio in the axis label (USD per EUR), ambiguous labels are penalised.', + 'Central bank intervention articles: draw the rate the bank defends and the excess demand/supply it must absorb, similar to a price control.', + ], + faq: [ + ['Which curve shifts when interest rates rise?', 'Higher domestic interest rates attract foreign capital: demand for the currency shifts right (and supply may shift left as residents keep funds at home), an appreciation. Describe the scenario and the AI shifts the correct curve.'], + ['Can I draw a fixed exchange rate?', 'Yes, add a horizontal line at the pegged rate, like a price control, and mark the intervention gap.'], + ['Does this work for any currency pair?', 'Yes, all labels are editable, so any base/quote pair works.'], + ], + axes: ['Quantity of EUR', 'Exchange rate (USD/EUR)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D_{EUR}'], + [10, 10, 90, 90, '#3b82f6', 'S_{EUR}'], + [25, 95, 90, 30, '#f97316', 'D_1', true], + ], + points: [[50, 50, 'e_1'], [60, 60, 'e_2']], + }, + related: ['ad-as-diagram', 'tariff-diagram', 'supply-and-demand'], + }, +]; diff --git a/scripts/update-supporters.mjs b/scripts/update-supporters.mjs new file mode 100644 index 0000000..b9cf891 --- /dev/null +++ b/scripts/update-supporters.mjs @@ -0,0 +1,73 @@ +// Maintainer script: refresh the Supporters section of README.md from the +// database. Requires the Supabase secret key — run locally, then commit the diff: +// +// SUPABASE_URL=... SUPABASE_SECRET_KEY=... node scripts/update-supporters.mjs + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createClient } from '@supabase/supabase-js'; + +const url = process.env.SUPABASE_URL; +const key = process.env.SUPABASE_SECRET_KEY; +if (!url || !key) { + console.error('Set SUPABASE_URL and SUPABASE_SECRET_KEY.'); + process.exit(1); +} + +const README = join(dirname(fileURLToPath(import.meta.url)), '..', 'README.md'); +const START = ''; +const END = ''; + +const supabase = createClient(url, key, { auth: { persistSession: false } }); + +// PostgREST caps a response at `db-max-rows` (1000 by default), so a single +// query silently drops everyone past the cap: the newest supporters would just +// stop appearing in the README once the list got long enough. Page until a +// short page comes back. +const PAGE_SIZE = 1000; +const now = new Date().toISOString(); +const data = []; +for (let from = 0; ; from += PAGE_SIZE) { + const { data: page, error } = await supabase + .from('profiles') + .select('supporter_name, pro_until, created_at') + .eq('show_in_supporters', true) + .not('supporter_name', 'is', null) + .gt('pro_until', now) + .order('created_at', { ascending: true }) + .range(from, from + PAGE_SIZE - 1); + + if (error) { + console.error('Query failed:', error.message); + process.exit(1); + } + data.push(...(page ?? [])); + if (!page || page.length < PAGE_SIZE) break; +} + +const names = data + .map((row) => row.supporter_name?.trim()) + .filter((name) => name && name.length <= 50) + // Markdown-escape to keep the README safe from user-controlled input. + .map((name) => name.replace(/[\\`*_{}[\]()#+\-.!|<>]/g, (c) => `\\${c}`)); + +const block = names.length > 0 + ? names.map((n) => `**${n}**`).join(' · ') + : '*Become the first. See the [Supporter plan](https://ib-econgraph-ai.vercel.app/pricing).*'; + +const readme = readFileSync(README, 'utf8'); +const startIdx = readme.indexOf(START); +const endIdx = readme.indexOf(END); +if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { + console.error(`Markers ${START} / ${END} not found in README.md.`); + process.exit(1); +} + +const updated = + readme.slice(0, startIdx + START.length) + + '\n\n' + block + '\n\n' + + readme.slice(endIdx); + +writeFileSync(README, updated); +console.log(`Updated README with ${names.length} supporter(s).`); diff --git a/services/ai.ts b/services/ai.ts index 5c1c620..a18592d 100644 --- a/services/ai.ts +++ b/services/ai.ts @@ -1,15 +1,23 @@ import { getAIProvider } from './aiProvider'; import { generateDiagramData as generateDiagramDataGemini, hasApiKey as hasGeminiApiKey } from './gemini'; import { generateDiagramDataOpenRouter, hasOpenRouterApiKey } from './openrouter'; +import { generateDiagramDataHosted } from './hostedAi'; import { DiagramData } from '../types'; +/** + * Whether the current BYOK provider has a key configured. For the hosted + * provider this is always true — availability is decided by auth/entitlement + * state, which callers check via `useAuth()` (see aiIsReady in App). + */ export function hasApiKey(): boolean { const provider = getAIProvider(); + if (provider === 'hosted') return true; return provider === 'openrouter' ? hasOpenRouterApiKey() : hasGeminiApiKey(); } export async function generateDiagramData(prompt: string, history: string[] = []): Promise { const provider = getAIProvider(); + if (provider === 'hosted') return generateDiagramDataHosted(prompt, history); return provider === 'openrouter' ? generateDiagramDataOpenRouter(prompt, history) : generateDiagramDataGemini(prompt, history); diff --git a/services/aiProvider.ts b/services/aiProvider.ts index 770cbba..b0169dc 100644 --- a/services/aiProvider.ts +++ b/services/aiProvider.ts @@ -1,10 +1,18 @@ -export type AIProvider = 'gemini' | 'openrouter'; +import { isCloudConfigured } from './supabaseClient'; + +export type AIProvider = 'gemini' | 'openrouter' | 'hosted'; const PROVIDER_STORAGE_KEY = 'econgraph_ai_provider'; export function getAIProvider(): AIProvider { const stored = localStorage.getItem(PROVIDER_STORAGE_KEY); - return stored === 'openrouter' ? 'openrouter' : 'gemini'; + // 'hosted' needs a cloud backend. If a deployment drops its Supabase + // configuration (or a user's storage is carried to a fork that has none), + // the stored choice would point at a provider the UI no longer offers and + // generation would fail with no way to change it: fall back to BYOK. + if (stored === 'hosted') return isCloudConfigured ? 'hosted' : 'gemini'; + if (stored === 'openrouter') return 'openrouter'; + return 'gemini'; } export function setAIProvider(provider: AIProvider): void { @@ -12,5 +20,9 @@ export function setAIProvider(provider: AIProvider): void { } export function getAIProviderDisplayName(provider: AIProvider = getAIProvider()): string { - return provider === 'openrouter' ? 'OpenRouter' : 'Google AI Studio'; + switch (provider) { + case 'openrouter': return 'OpenRouter'; + case 'hosted': return 'EconGraph Cloud'; + default: return 'Google AI Studio'; + } } diff --git a/services/auth.tsx b/services/auth.tsx new file mode 100644 index 0000000..66a4366 --- /dev/null +++ b/services/auth.tsx @@ -0,0 +1,260 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import type { Session, User } from '@supabase/supabase-js'; +import { supabase, isCloudConfigured } from './supabaseClient'; +import { clearTemplateCache } from './customTemplates'; +import { isProUntilActive } from './entitlement'; + +export interface Profile { + id: string; + email: string | null; + display_name: string | null; + supporter_name: string | null; + show_in_supporters: boolean; + pro_status: string; + pro_until: string | null; + plan_interval: string | null; +} + +export type EditableProfileFields = Partial>; + +interface AuthContextValue { + /** Whether Supabase is configured for this deployment at all. */ + configured: boolean; + /** True until the initial session restore has finished. */ + loading: boolean; + session: Session | null; + user: User | null; + profile: Profile | null; + /** Active Supporter (Pro) entitlement. */ + isPro: boolean; + /** + * True after the user follows a password-reset link (Supabase fires a + * PASSWORD_RECOVERY event). The Settings page uses this to prompt for a new + * password. + */ + recoveryMode: boolean; + /** Create an account with email + password. `needsConfirmation` when a + * verification email was sent and no session was established yet. + * `returnTo` is the in-app path to land on afterwards (default `/settings`). */ + signUpWithPassword: (email: string, password: string, returnTo?: string) => Promise<{ error?: string; needsConfirmation?: boolean }>; + signInWithPassword: (email: string, password: string) => Promise<{ error?: string }>; + /** Send a password-reset email. */ + resetPassword: (email: string) => Promise<{ error?: string }>; + /** Set a new password for the signed-in (or recovering) user. */ + updatePassword: (password: string) => Promise<{ error?: string }>; + clearRecoveryMode: () => void; + /** `returnTo` is the in-app path to land on afterwards (default `/settings`). */ + signInWithGoogle: (returnTo?: string) => Promise<{ error?: string }>; + signOut: () => Promise; + refreshProfile: () => Promise; + updateProfile: (patch: EditableProfileFields) => Promise<{ error?: string }>; +} + +const NOT_CONFIGURED = { error: 'Accounts are not available on this deployment.' } as const; + +const AuthContext = createContext({ + configured: false, + loading: false, + session: null, + user: null, + profile: null, + isPro: false, + recoveryMode: false, + signUpWithPassword: async () => NOT_CONFIGURED, + signInWithPassword: async () => NOT_CONFIGURED, + resetPassword: async () => NOT_CONFIGURED, + updatePassword: async () => NOT_CONFIGURED, + clearRecoveryMode: () => { }, + signInWithGoogle: async () => NOT_CONFIGURED, + signOut: async () => { }, + refreshProfile: async () => { }, + updateProfile: async () => NOT_CONFIGURED, +}); + +export function profileIsPro(profile: Profile | null): boolean { + return isProUntilActive(profile?.pro_until); +} + +/** + * Absolute URL for an auth redirect back into the app. Only a same-origin path + * is accepted: anything else (a full URL, a protocol-relative `//host` that the + * browser would treat as another origin, a backslash variant some parsers + * normalise to `/`) falls back to Settings, so a redirect target can never be + * pointed off-site. + */ +function authRedirectUrl(returnTo?: string): string { + const safe = + returnTo && /^\/[A-Za-z0-9._~\-/]*$/.test(returnTo) && !returnTo.startsWith('//') + ? returnTo + : '/settings'; + return `${window.location.origin}${safe}`; +} + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [session, setSession] = useState(null); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(isCloudConfigured); + const [recoveryMode, setRecoveryMode] = useState(false); + const userIdRef = useRef(null); + + const fetchProfile = useCallback(async (userId: string | null) => { + if (!supabase || !userId) { + setProfile(null); + return; + } + const { data, error } = await supabase + .from('profiles') + .select('id, email, display_name, supporter_name, show_in_supporters, pro_status, pro_until, plan_interval') + .eq('id', userId) + .maybeSingle(); + if (!error && userIdRef.current === userId) { + setProfile((data as Profile) ?? null); + } + }, []); + + useEffect(() => { + if (!supabase) return; + + let cancelled = false; + supabase.auth.getSession().then(({ data }) => { + if (cancelled) return; + setSession(data.session); + userIdRef.current = data.session?.user?.id ?? null; + fetchProfile(userIdRef.current).finally(() => { + if (!cancelled) setLoading(false); + }); + }).catch((err) => { + // Don't leave the UI stuck on the loading spinner if session + // restore fails (transient network/storage error). + console.error('auth: getSession failed', err); + if (!cancelled) setLoading(false); + }); + + const { data: sub } = supabase.auth.onAuthStateChange((event, newSession) => { + // Arrived via a password-reset link → prompt for a new password. + if (event === 'PASSWORD_RECOVERY') setRecoveryMode(true); + setSession(newSession); + const newUserId = newSession?.user?.id ?? null; + if (newUserId !== userIdRef.current) { + if (!newUserId) clearTemplateCache(); // signed out / expired elsewhere + userIdRef.current = newUserId; + // Drop the old profile immediately. Leaving it in place until the + // replacement query resolves shows the previous account's name + // and Supporter status under the new session. + setProfile(null); + fetchProfile(newUserId); + } + }); + + return () => { + cancelled = true; + sub.subscription.unsubscribe(); + }; + }, [fetchProfile]); + + const signUpWithPassword = useCallback(async (email: string, password: string, returnTo?: string) => { + if (!supabase) return NOT_CONFIGURED; + const { data, error } = await supabase.auth.signUp({ + email: email.trim(), + password, + options: { emailRedirectTo: authRedirectUrl(returnTo) }, + }); + if (error) return { error: error.message }; + // Session present → email confirmation is disabled, user is signed in. + if (data.session) return {}; + // Supabase deliberately does NOT say whether the address is already + // registered: it returns a user with an empty `identities` array instead + // of an error, precisely so the endpoint can't be used to enumerate + // accounts. Reporting "an account already exists" here would undo that, + // so both cases get the identical confirmation screen. Someone who does + // own the address learns the truth from the mail they receive; someone + // probing addresses learns nothing. + return { needsConfirmation: true }; + }, []); + + const signInWithPassword = useCallback(async (email: string, password: string) => { + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.signInWithPassword({ email: email.trim(), password }); + if (!error) return {}; + // Friendlier copy for the common "not confirmed yet" case. + if (/email not confirmed/i.test(error.message)) { + return { error: 'Please confirm your email first, check your inbox for the verification link.' }; + } + return { error: error.message }; + }, []); + + const resetPassword = useCallback(async (email: string) => { + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { + redirectTo: `${window.location.origin}/settings`, + }); + return error ? { error: error.message } : {}; + }, []); + + const updatePassword = useCallback(async (password: string) => { + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.updateUser({ password }); + if (error) return { error: error.message }; + setRecoveryMode(false); + return {}; + }, []); + + const clearRecoveryMode = useCallback(() => setRecoveryMode(false), []); + + const signInWithGoogle = useCallback(async (returnTo?: string) => { + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: authRedirectUrl(returnTo) }, + }); + return error ? { error: error.message } : {}; + }, []); + + const signOut = useCallback(async () => { + if (!supabase) return; + clearTemplateCache(); + setRecoveryMode(false); + await supabase.auth.signOut(); + setProfile(null); + }, []); + + const refreshProfile = useCallback(async () => { + await fetchProfile(userIdRef.current); + }, [fetchProfile]); + + const updateProfile = useCallback(async (patch: EditableProfileFields) => { + if (!supabase || !userIdRef.current) return { error: 'Not signed in.' }; + const { error } = await supabase + .from('profiles') + .update(patch) + .eq('id', userIdRef.current); + if (error) return { error: error.message }; + await fetchProfile(userIdRef.current); + return {}; + }, [fetchProfile]); + + const value = useMemo(() => ({ + configured: isCloudConfigured, + loading, + session, + user: session?.user ?? null, + profile, + isPro: profileIsPro(profile), + recoveryMode, + signUpWithPassword, + signInWithPassword, + resetPassword, + updatePassword, + clearRecoveryMode, + signInWithGoogle, + signOut, + refreshProfile, + updateProfile, + }), [loading, session, profile, recoveryMode, signUpWithPassword, signInWithPassword, resetPassword, updatePassword, clearRecoveryMode, signInWithGoogle, signOut, refreshProfile, updateProfile]); + + return {children}; +}; + +export function useAuth(): AuthContextValue { + return useContext(AuthContext); +} diff --git a/services/billing.ts b/services/billing.ts new file mode 100644 index 0000000..05e3815 --- /dev/null +++ b/services/billing.ts @@ -0,0 +1,62 @@ +import { getAccessToken } from './supabaseClient'; +import { fetchWithTimeout, RequestTimeoutError } from './httpTimeout'; + +async function callBillingEndpoint(path: string, body?: unknown): Promise<{ url?: string; error?: string }> { + const token = await getAccessToken(); + if (!token) return { error: 'Please sign in first.' }; + + try { + const res = await fetchWithTimeout(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await res.json().catch(() => null) as { url?: string; error?: string } | null; + if (!res.ok || !data?.url) { + return { error: data?.error || 'Something went wrong. Please try again.' }; + } + return { url: data.url }; + } catch (err) { + if (err instanceof RequestTimeoutError) return { error: err.message }; + return { error: 'Could not reach the server. Check your connection and try again.' }; + } +} + +/** Start a Polar checkout for the Supporter plan. Returns the checkout URL. */ +export function startCheckout(interval: 'month' | 'year') { + return callBillingEndpoint('/api/checkout', { interval }); +} + +/** Open the Polar customer portal (manage / cancel subscription, invoices). */ +export function openBillingPortal() { + return callBillingEndpoint('/api/portal'); +} + +/** + * Permanently delete the signed-in user's account and all cloud data (cancels + * any active subscription first). Returns {} on success, or { error }. + */ +export async function deleteAccount(): Promise<{ error?: string }> { + const token = await getAccessToken(); + if (!token) return { error: 'Please sign in first.' }; + try { + const res = await fetchWithTimeout('/api/delete-account', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json().catch(() => null) as { deleted?: boolean; error?: string } | null; + if (!res.ok || !data?.deleted) { + return { error: data?.error || 'Could not delete your account. Please try again.' }; + } + return {}; + } catch (err) { + if (err instanceof RequestTimeoutError) { + // Deletion may still be running server-side, so don't imply it failed. + return { error: 'The server took too long to respond. Reload and check whether your account was deleted before trying again.' }; + } + return { error: 'Could not reach the server. Check your connection and try again.' }; + } +} diff --git a/services/cloudErrors.ts b/services/cloudErrors.ts new file mode 100644 index 0000000..a30c76e --- /dev/null +++ b/services/cloudErrors.ts @@ -0,0 +1,9 @@ +/** + * A row-level-security denial is how Supabase reports a write blocked by a + * Supporter-gated RLS policy. Detecting it lets each cloud feature show a + * friendly "this is part of the Supporter plan" message instead of a raw + * Postgres error. Shared so the (fragile) detection string lives in one place. + */ +export function isRlsDenied(message: string): boolean { + return /row-level security/i.test(message); +} diff --git a/services/customTemplates.ts b/services/customTemplates.ts new file mode 100644 index 0000000..b804019 --- /dev/null +++ b/services/customTemplates.ts @@ -0,0 +1,149 @@ +import { supabase } from './supabaseClient'; +import { DiagramData } from '../types'; +import { isRlsDenied } from './cloudErrors'; + +export interface CustomTemplate { + id: string; + name: string; + description: string; + data: Partial; + createdAt: number; +} + +const CACHE_KEY = 'econgraph_custom_templates_v1'; + +// The cache is tagged with its owning user so it can never be shown to a +// different (or signed-out) account on a shared browser. +interface TemplateCache { + userId: string; + templates: CustomTemplate[]; +} + +function readCache(userId: string): CustomTemplate[] { + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as TemplateCache; + if (parsed?.userId !== userId || !Array.isArray(parsed.templates)) return []; + return parsed.templates; + } catch { + return []; + } +} + +function writeCache(userId: string, templates: CustomTemplate[]): void { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify({ userId, templates } satisfies TemplateCache)); + } catch { /* quota — cache is best-effort */ } +} + +/** Clear the local template cache (call on sign-out). */ +export function clearTemplateCache(): void { + try { + localStorage.removeItem(CACHE_KEY); + } catch { /* ignore */ } +} + +/** Instant, offline-friendly read of the local cache for a specific user. */ +export function listCachedTemplates(userId: string): CustomTemplate[] { + return readCache(userId); +} + +/** Pull the authoritative list from the cloud and refresh the cache. */ +export async function fetchCustomTemplates(userId: string): Promise { + if (!supabase) return readCache(userId); + const { data, error } = await supabase + .from('templates') + .select('id, name, description, data, last_modified') + .order('last_modified', { ascending: false }); + if (error) return readCache(userId); + const templates: CustomTemplate[] = (data ?? []).map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + data: row.data as Partial, + createdAt: row.last_modified, + })); + writeCache(userId, templates); + return templates; +} + +/** Extract reusable content from the current diagram. */ +export function templateDataFromDiagram(diagram: DiagramData): Partial { + return { + curves: diagram.curves, + shadedRegions: diagram.shadedRegions, + annotatedPoints: diagram.annotatedPoints, + textLabels: diagram.textLabels ?? [], + }; +} + +export async function saveCustomTemplate( + userId: string, + input: { name: string; description?: string; data: Partial }, +): Promise<{ template?: CustomTemplate; error?: string }> { + if (!supabase) return { error: 'Custom templates are not available on this deployment.' }; + const template: CustomTemplate = { + id: crypto.randomUUID(), + name: input.name.trim(), + description: input.description?.trim() ?? '', + data: input.data, + createdAt: Date.now(), + }; + if (!template.name) return { error: 'Please give the template a name.' }; + + const { error } = await supabase.from('templates').insert({ + id: template.id, + user_id: userId, + name: template.name, + description: template.description, + category: 'custom', + data: template.data, + last_modified: template.createdAt, + }); + if (error) { + if (isRlsDenied(error.message)) { + return { error: 'Custom templates are part of the Supporter plan.' }; + } + return { error: error.message }; + } + writeCache(userId, [template, ...readCache(userId)]); + return { template }; +} + +export async function deleteCustomTemplate(userId: string, id: string): Promise<{ error?: string }> { + if (!supabase) return { error: 'Custom templates are not available on this deployment.' }; + const { error } = await supabase.from('templates').delete().eq('id', id); + if (error) return { error: error.message }; + writeCache(userId, readCache(userId).filter((t) => t.id !== id)); + return {}; +} + +export interface CloudVersion { + id: string; + graphId: string; + title: string; + data: unknown; + lastModified: number; + createdAt: string; +} + +/** Version history for a graph (Supporter feature; newest first). */ +export async function fetchGraphVersions(graphId: string): Promise { + if (!supabase) return []; + const { data, error } = await supabase + .from('graph_versions') + .select('id, graph_id, title, data, last_modified, created_at') + .eq('graph_id', graphId) + .order('created_at', { ascending: false }) + .limit(30); + if (error) return []; + return (data ?? []).map((row) => ({ + id: row.id, + graphId: row.graph_id, + title: row.title, + data: row.data, + lastModified: row.last_modified, + createdAt: row.created_at, + })); +} diff --git a/services/diagramPrompt.ts b/services/diagramPrompt.ts new file mode 100644 index 0000000..fd74811 --- /dev/null +++ b/services/diagramPrompt.ts @@ -0,0 +1,219 @@ +import { Type, Schema } from "@google/genai"; + +// Shared between the browser (BYOK Gemini provider) and the serverless hosted +// AI endpoint (api/generate.ts). Keep this module free of browser-only APIs. + +/** + * The economics rules every provider shares. Only the closing output + * instruction differs between them, so that part is appended per provider + * below rather than the whole prompt being copied. + */ +const DIAGRAM_RULES = ` + You are an expert Economics Professor and SVG Graph Generator. + Your goal is to generate precise coordinate data for economic diagrams based on user prompts. + + Rules for generation: + 1. Coordinate System: Use a logical scale (e.g., 0-10 or 0-100). Keep it consistent. + 2. Accuracy: Calculate intersection points mathematically. If Supply is P = 10 + Q and Demand is P = 100 - Q, Equilibrium is Q=45, P=55. + 3. Shared Coordinates (CRITICAL): + - If an equilibrium point E is at (50, 50), ensure the Supply Curve, Demand Curve, and any Shaded Regions ALL use the exact coordinate (50, 50). + - Do not approximate. If a shaded region (e.g., Consumer Surplus) is bounded by the Price axis, Demand curve, and Equilibrium price, the vertices must strictly match the curve points. + 4. Shading: + - Provide a closed polygon for shaded areas. + 5. Labels: + - Use LaTeX-style formatting for subscripts and superscripts. + - Example: "P_1", "Q^*", "Q_{tax}", "D_{private}". + 6. Context: + - If the user asks for "Monopoly", ensure MR is below D. + - If the user asks for "Tax", shift the appropriate curve. +`; + +/** Gemini (BYOK and hosted): the response schema below enforces the shape. */ +export const DIAGRAM_SYSTEM_INSTRUCTION = `${DIAGRAM_RULES} + Output purely the JSON object matching the schema. + `; + +/** + * OpenRouter: an arbitrary model behind a plain chat completion, with no + * server-side schema enforcement, so the shape has to be spelled out and prose + * and markdown fences explicitly ruled out. + */ +export const OPENROUTER_SYSTEM_INSTRUCTION = `${DIAGRAM_RULES} + Output requirements (STRICT): + - Output ONLY a JSON object (no prose). + - Do NOT wrap in markdown. + - The JSON must match the DiagramData shape used by this app: { title, summary, xAxis, yAxis, curves, annotatedPoints, shadedRegions }. + `; + +export const GEMINI_DIAGRAM_SCHEMA: Schema = { + type: Type.OBJECT, + properties: { + title: { type: Type.STRING, description: "Title of the economic diagram" }, + summary: { type: Type.STRING, description: "Brief explanation of what the diagram shows" }, + xAxis: { + type: Type.OBJECT, + properties: { + label: { type: Type.STRING, description: "Label for X axis (e.g. Quantity)" }, + min: { type: Type.NUMBER, description: "Always 0 usually" }, + max: { type: Type.NUMBER, description: "Scale maximum, usually 10 or 100" } + }, + required: ["label", "min", "max"] + }, + yAxis: { + type: Type.OBJECT, + properties: { + label: { type: Type.STRING, description: "Label for Y axis (e.g. Price)" }, + min: { type: Type.NUMBER }, + max: { type: Type.NUMBER } + }, + required: ["label", "min", "max"] + }, + curves: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + id: { type: Type.STRING }, + label: { type: Type.STRING, description: "Label like D, S, MC, ATC. Use _ for subscript (D_1) and ^ for superscript." }, + color: { type: Type.STRING, description: "Hex code. Use standard colors: Red #ef4444 for Demand/Marginal Benefit, Blue #3b82f6 for Supply/MC, etc." }, + type: { type: Type.STRING, enum: ["linear", "bezier", "vertical", "horizontal"] }, + width: { type: Type.NUMBER, description: "Stroke width, default 2" }, + strokeDasharray: { type: Type.STRING, description: "Optional, e.g. '5,5' for dashed" }, + points: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + x: { type: Type.NUMBER }, + y: { type: Type.NUMBER } + }, + required: ["x", "y"] + }, + description: "2 points for linear, 3 points for bezier (start, control, end)" + } + }, + required: ["id", "label", "color", "type", "points", "width"] + } + }, + annotatedPoints: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + x: { type: Type.NUMBER }, + y: { type: Type.NUMBER }, + label: { type: Type.STRING, description: "e.g. E_1, P^*, Q_0. Use _ for subscript and ^ for superscript." }, + labelPosition: { type: Type.STRING, enum: ["top", "bottom", "left", "right", "top-right", "top-left", "bottom-right", "bottom-left"] }, + showDottedLines: { type: Type.BOOLEAN, description: "If true, draws dotted lines to both axes" }, + color: { type: Type.STRING } + }, + required: ["x", "y", "label", "showDottedLines"] + } + }, + shadedRegions: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + id: { type: Type.STRING }, + label: { type: Type.STRING, description: "Label for the area (e.g. DWL, CS, PS)" }, + color: { type: Type.STRING, description: "RGBA color string, e.g., 'rgba(239, 68, 68, 0.2)'" }, + points: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + x: { type: Type.NUMBER }, + y: { type: Type.NUMBER } + }, + required: ["x", "y"] + }, + description: "Ordered vertices of the polygon to fill." + } + }, + required: ["id", "label", "color", "points"] + } + } + }, + required: ["title", "xAxis", "yAxis", "curves", "annotatedPoints", "shadedRegions", "summary"] +}; + +export function buildHistoryContext(history: string[]): string { + return history.length > 0 + ? `Previous context:\n${history.join("\n")}\n\nCurrent Request:` + : "Request:"; +} + +/** + * Runtime shape check for a model-produced diagram, shared by every provider + * (hosted `api/generate`, the hosted client, BYOK Gemini, OpenRouter). + * + * A response-schema request is a strong hint, not a guarantee: OpenRouter has no + * schema at all, and even Gemini can return a truncated or partial object. The + * renderer reads `curves[].points[].x` and scales by `xAxis.max - xAxis.min` + * without guarding, so a missing array or a non-finite bound throws or produces + * NaN geometry rather than a usable error. Checking the axis objects alone (the + * previous test) let all of that through. + * + * Returns null when valid, or a short reason for logging. + */ +export function diagramShapeError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return "not an object"; + const d = value as Record; + + for (const key of ["title", "summary"] as const) { + if (typeof d[key] !== "string") return `${key} is not a string`; + } + + for (const key of ["xAxis", "yAxis"] as const) { + const axis = d[key]; + if (!axis || typeof axis !== "object" || Array.isArray(axis)) return `${key} is missing`; + const a = axis as Record; + if (typeof a.label !== "string") return `${key}.label is not a string`; + if (typeof a.min !== "number" || !Number.isFinite(a.min)) return `${key}.min is not finite`; + if (typeof a.max !== "number" || !Number.isFinite(a.max)) return `${key}.max is not finite`; + // Equal bounds would make the renderer divide by a zero-width range. + if (a.max <= a.min) return `${key}.max is not greater than ${key}.min`; + } + + for (const key of ["curves", "annotatedPoints", "shadedRegions"] as const) { + if (!Array.isArray(d[key])) return `${key} is not an array`; + } + + const hasFinitePoints = (points: unknown): boolean => + Array.isArray(points) && + points.every( + (p) => + !!p && + typeof p === "object" && + Number.isFinite((p as { x?: unknown }).x as number) && + Number.isFinite((p as { y?: unknown }).y as number), + ); + + for (const curve of d.curves as unknown[]) { + if (!curve || typeof curve !== "object") return "a curve is not an object"; + const c = curve as Record; + // A curve with no usable geometry renders as nothing at best and throws at + // worst, so treat it as a failed generation rather than a blank diagram. + if (!hasFinitePoints(c.points) || (c.points as unknown[]).length < 2) { + return "a curve has fewer than two finite points"; + } + } + + for (const region of d.shadedRegions as unknown[]) { + if (!region || typeof region !== "object") return "a shaded region is not an object"; + if (!hasFinitePoints((region as Record).points)) { + return "a shaded region has non-finite points"; + } + } + + for (const point of d.annotatedPoints as unknown[]) { + if (!point || typeof point !== "object") return "an annotated point is not an object"; + const p = point as Record; + if (!Number.isFinite(p.x as number) || !Number.isFinite(p.y as number)) { + return "an annotated point has non-finite coordinates"; + } + } + + return null; +} diff --git a/services/entitlement.ts b/services/entitlement.ts new file mode 100644 index 0000000..f382aeb --- /dev/null +++ b/services/entitlement.ts @@ -0,0 +1,29 @@ +/** + * Single source of truth for the "active Supporter" entitlement rule, shared by + * the client (services/auth.tsx) and the serverless API (api/_lib/supabaseAdmin). + * A profile is entitled when its paid-through timestamp is set and still in the + * future. Pure (no imports) so it's safe to use in both runtimes. + * + * NOTE: the Postgres `is_pro()` function in supabase/schema.sql enforces the same + * rule inside RLS policies — keep the two in sync if this ever changes. + */ +export function isProUntilActive(proUntil: string | null | undefined): boolean { + if (!proUntil) return false; + return Date.parse(proUntil) > Date.now(); +} + +/** + * Polar subscription statuses that count as a live subscription: the user is + * either paying, in a trial, or behind on payment but not yet cancelled. Used + * to decide whether to offer a second checkout, whether an account deletion + * must revoke first, and whether a webhook grants entitlement. + * + * Shared so those three answers cannot drift apart. `past_due` is included on + * purpose: Polar is still retrying the charge, and dropping access mid-retry + * would punish a user whose card simply needs updating. + */ +export const ENTITLED_POLAR_STATUSES: ReadonlySet = new Set([ + 'active', + 'trialing', + 'past_due', +]); diff --git a/services/gemini.ts b/services/gemini.ts index e7020d4..fbe0881 100644 --- a/services/gemini.ts +++ b/services/gemini.ts @@ -1,37 +1,24 @@ -import { GoogleGenAI, Type, Schema } from "@google/genai"; +import { GoogleGenAI } from "@google/genai"; import { DiagramData } from "../types"; +import { DIAGRAM_SYSTEM_INSTRUCTION, GEMINI_DIAGRAM_SCHEMA, buildHistoryContext } from "./diagramPrompt"; +import { obfuscateKey, deobfuscateKey } from "./keyObfuscation"; const STORAGE_KEY = 'econgraph_api_key'; const MODEL_STORAGE_KEY = 'econgraph_selected_model'; -// Simple obfuscation to avoid plain-text keys in localStorage. -// This is NOT encryption — true encryption is impossible when the -// decryption key must also live client-side. The purpose is to -// prevent casual exposure (e.g. shoulder-surfing DevTools). -const OBFUSCATION_PREFIX = 'egk_'; - -function obfuscate(key: string): string { - return OBFUSCATION_PREFIX + btoa(key); -} - -function deobfuscate(stored: string): string { - if (!stored.startsWith(OBFUSCATION_PREFIX)) return stored; - return atob(stored.slice(OBFUSCATION_PREFIX.length)); -} - export function saveApiKey(key: string): void { if (!key.trim()) { localStorage.removeItem(STORAGE_KEY); return; } - localStorage.setItem(STORAGE_KEY, obfuscate(key.trim())); + localStorage.setItem(STORAGE_KEY, obfuscateKey(key.trim())); } export function getApiKey(): string { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return ''; try { - return deobfuscate(stored); + return deobfuscateKey(stored); } catch { return ''; } @@ -100,99 +87,6 @@ export async function fetchAvailableModels(): Promise { } } -const diagramSchema: Schema = { - type: Type.OBJECT, - properties: { - title: { type: Type.STRING, description: "Title of the economic diagram" }, - summary: { type: Type.STRING, description: "Brief explanation of what the diagram shows" }, - xAxis: { - type: Type.OBJECT, - properties: { - label: { type: Type.STRING, description: "Label for X axis (e.g. Quantity)" }, - min: { type: Type.NUMBER, description: "Always 0 usually" }, - max: { type: Type.NUMBER, description: "Scale maximum, usually 10 or 100" } - }, - required: ["label", "min", "max"] - }, - yAxis: { - type: Type.OBJECT, - properties: { - label: { type: Type.STRING, description: "Label for Y axis (e.g. Price)" }, - min: { type: Type.NUMBER }, - max: { type: Type.NUMBER } - }, - required: ["label", "min", "max"] - }, - curves: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - id: { type: Type.STRING }, - label: { type: Type.STRING, description: "Label like D, S, MC, ATC. Use _ for subscript (D_1) and ^ for superscript." }, - color: { type: Type.STRING, description: "Hex code. Use standard colors: Red #ef4444 for Demand/Marginal Benefit, Blue #3b82f6 for Supply/MC, etc." }, - type: { type: Type.STRING, enum: ["linear", "bezier", "vertical", "horizontal"] }, - width: { type: Type.NUMBER, description: "Stroke width, default 2" }, - strokeDasharray: { type: Type.STRING, description: "Optional, e.g. '5,5' for dashed" }, - points: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - x: { type: Type.NUMBER }, - y: { type: Type.NUMBER } - }, - required: ["x", "y"] - }, - description: "2 points for linear, 3 points for bezier (start, control, end)" - } - }, - required: ["id", "label", "color", "type", "points", "width"] - } - }, - annotatedPoints: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - x: { type: Type.NUMBER }, - y: { type: Type.NUMBER }, - label: { type: Type.STRING, description: "e.g. E_1, P^*, Q_0. Use _ for subscript and ^ for superscript." }, - labelPosition: { type: Type.STRING, enum: ["top", "bottom", "left", "right", "top-right", "top-left", "bottom-right", "bottom-left"] }, - showDottedLines: { type: Type.BOOLEAN, description: "If true, draws dotted lines to both axes" }, - color: { type: Type.STRING } - }, - required: ["x", "y", "label", "showDottedLines"] - } - }, - shadedRegions: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - id: { type: Type.STRING }, - label: { type: Type.STRING, description: "Label for the area (e.g. DWL, CS, PS)" }, - color: { type: Type.STRING, description: "RGBA color string, e.g., 'rgba(239, 68, 68, 0.2)'" }, - points: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - x: { type: Type.NUMBER }, - y: { type: Type.NUMBER } - }, - required: ["x", "y"] - }, - description: "Ordered vertices of the polygon to fill." - } - }, - required: ["id", "label", "color", "points"] - } - } - }, - required: ["title", "xAxis", "yAxis", "curves", "annotatedPoints", "shadedRegions", "summary"] -}; - export async function generateDiagramData(prompt: string, history: string[] = []): Promise { const apiKey = getApiKey(); if (!apiKey) { @@ -202,41 +96,14 @@ export async function generateDiagramData(prompt: string, history: string[] = [] const ai = new GoogleGenAI({ apiKey }); const model = getSelectedModel(); - // Convert history to a text context block - const historyContext = history.length > 0 - ? `Previous context:\n${history.join("\n")}\n\nCurrent Request:` - : "Request:"; - - const systemInstruction = ` - You are an expert Economics Professor and SVG Graph Generator. - Your goal is to generate precise coordinate data for economic diagrams based on user prompts. - - Rules for generation: - 1. Coordinate System: Use a logical scale (e.g., 0-10 or 0-100). Keep it consistent. - 2. Accuracy: Calculate intersection points mathematically. If Supply is P = 10 + Q and Demand is P = 100 - Q, Equilibrium is Q=45, P=55. - 3. Shared Coordinates (CRITICAL): - - If an equilibrium point E is at (50, 50), ensuring the Supply Curve, Demand Curve, and any Shaded Regions ALL use the exact coordinate (50, 50). - - Do not approximate. If a shaded region (e.g., Consumer Surplus) is bounded by the Price axis, Demand curve, and Equilibrium price, the vertices must strictly match the curve points. - 4. Shading: - - Provide a closed polygon for shaded areas. - 5. Labels: - - Use LaTeX-style formatting for subscripts and superscripts. - - Example: "P_1", "Q^*", "Q_{tax}", "D_{private}". - 6. Context: - - If the user asks for "Monopoly", ensure MR is below D. - - If the user asks for "Tax", shift the appropriate curve. - - Output purely the JSON object matching the schema. - `; - try { const response = await ai.models.generateContent({ model, - contents: `${historyContext} ${prompt}`, + contents: `${buildHistoryContext(history)} ${prompt}`, config: { - systemInstruction, + systemInstruction: DIAGRAM_SYSTEM_INSTRUCTION, responseMimeType: "application/json", - responseSchema: diagramSchema, + responseSchema: GEMINI_DIAGRAM_SCHEMA, temperature: 0.2, // Lower temperature for better math consistency } }); diff --git a/services/hostedAi.ts b/services/hostedAi.ts new file mode 100644 index 0000000..11f4cff --- /dev/null +++ b/services/hostedAi.ts @@ -0,0 +1,71 @@ +import { DiagramData } from '../types'; +import { getAccessToken } from './supabaseClient'; +import { diagramShapeError } from './diagramPrompt'; +import { fetchWithTimeout, GENERATE_TIMEOUT_MS, RequestTimeoutError } from './httpTimeout'; + +export interface HostedUsage { + used: number; + limit: number; + month: string; + isPro: boolean; +} + +/** + * Generate a diagram through the hosted (server-side) AI endpoint. + * Requires a signed-in Supporter, the server enforces both. + */ +export async function generateDiagramDataHosted(prompt: string, history: string[] = []): Promise { + const token = await getAccessToken(); + if (!token) { + throw new Error('Please sign in (Settings > Account) to use hosted AI, or switch to your own API key.'); + } + + let res: Response; + try { + res = await fetchWithTimeout('/api/generate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ prompt, history }), + }, GENERATE_TIMEOUT_MS); + } catch (err) { + if (err instanceof RequestTimeoutError) throw err; + throw new Error('Could not reach the server. Check your connection and try again.'); + } + + const body = await res.json().catch(() => null) as + | { diagram?: DiagramData; error?: string } + | null; + + if (!res.ok || !body?.diagram) { + throw new Error(body?.error || 'Hosted AI generation failed. Please try again.'); + } + // Guard the shape too. The server checks it as well, but this is the last + // point before the renderer, which reads curve points and axis bounds + // without guarding and turns anything malformed into NaN geometry. + const shapeError = diagramShapeError(body.diagram); + if (shapeError) { + console.error(`hosted AI: unusable diagram (${shapeError})`); + throw new Error('The AI returned an unexpected result. Please try again.'); + } + return body.diagram; +} + +/** Fetch the signed-in user's hosted AI usage. Returns null when unavailable. */ +export async function fetchHostedUsage(): Promise { + try { + // Inside the try: a failed session restore should read as "no usage to + // show", not reject and leave callers with an unhandled rejection. + const token = await getAccessToken(); + if (!token) return null; + const res = await fetchWithTimeout('/api/usage', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; + return await res.json() as HostedUsage; + } catch { + return null; + } +} diff --git a/services/httpTimeout.ts b/services/httpTimeout.ts new file mode 100644 index 0000000..2d8f28f --- /dev/null +++ b/services/httpTimeout.ts @@ -0,0 +1,69 @@ +/** + * `fetch()` has no default timeout. A server that accepts the connection and + * then stalls leaves the promise pending indefinitely, and with it whatever + * spinner the caller is showing: the user's only recourse is reloading the page. + * Every first-party API call goes through this wrapper so a hung request + * surfaces as an ordinary error the UI can render. + */ + +/** Enough for a normal round trip to a serverless function, including a cold start. */ +export const DEFAULT_TIMEOUT_MS = 20_000; + +/** + * Diagram generation waits on an upstream model. The server bounds that at 30s + * and the platform kills the function at 60s (`maxDuration` in vercel.json), so + * this sits just past both: the server's own error message wins whenever there + * is one, and this only fires if the connection itself has died. + */ +export const GENERATE_TIMEOUT_MS = 65_000; + +export class RequestTimeoutError extends Error { + constructor(message = 'The server took too long to respond. Please try again.') { + super(message); + this.name = 'RequestTimeoutError'; + } +} + +export async function fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + // Which one actually stopped the request. Reading `aborted` off the two + // signals afterwards cannot tell us: a caller who cancels just after the + // deadline fires leaves both set, and the request would be reported as + // their cancellation when it was really a timeout. First one wins. + let cause: 'timeout' | 'caller' | null = null; + + const timer = setTimeout(() => { + cause ??= 'timeout'; + controller.abort(); + }, timeoutMs); + + // Passing `signal: controller.signal` overwrites whatever the caller put in + // `init`, so their own cancellation would quietly stop working. Chain the + // two instead: either one aborts the request, and the caller's reason is + // carried through so they can tell why. + const external = init.signal ?? undefined; + const forwardAbort = () => { + cause ??= 'caller'; + controller.abort(external!.reason); + }; + if (external) { + if (external.aborted) forwardAbort(); + else external.addEventListener('abort', forwardAbort, { once: true }); + } + + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (err) { + // Our own deadline becomes a message the UI can render. A caller's + // abort is theirs to describe, so it propagates untouched. + if (cause === 'timeout') throw new RequestTimeoutError(); + throw err; + } finally { + clearTimeout(timer); + external?.removeEventListener('abort', forwardAbort); + } +} diff --git a/services/keyObfuscation.ts b/services/keyObfuscation.ts new file mode 100644 index 0000000..f770e09 --- /dev/null +++ b/services/keyObfuscation.ts @@ -0,0 +1,30 @@ +// Simple obfuscation to avoid plain-text API keys sitting in localStorage. +// This is NOT encryption — true encryption is impossible when the decryption +// key must also live client-side. The purpose is only to prevent casual +// exposure (e.g. shoulder-surfing DevTools). Shared by every BYO-key provider. +const OBFUSCATION_PREFIX = 'egk_'; + +// btoa/atob only handle Latin-1. A key pasted with any character above U+00FF +// (or a stray smart quote) would throw InvalidCharacterError out of the save +// path, so round-trip through UTF-8 bytes instead. +function toBase64(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function fromBase64(encoded: string): string { + const binary = atob(encoded); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +export function obfuscateKey(key: string): string { + return OBFUSCATION_PREFIX + toBase64(key); +} + +export function deobfuscateKey(stored: string): string { + if (!stored.startsWith(OBFUSCATION_PREFIX)) return stored; + return fromBase64(stored.slice(OBFUSCATION_PREFIX.length)); +} diff --git a/services/localStore.ts b/services/localStore.ts new file mode 100644 index 0000000..a2503b6 --- /dev/null +++ b/services/localStore.ts @@ -0,0 +1,479 @@ +import { Graph, Project } from '../types'; + +/** + * Per-account local storage for diagrams and projects. + * + * The browser's local store is shared by everyone who uses the browser, but the + * app's content is not: two people signing into the same browser must never see + * (or overwrite) each other's diagrams. Every account therefore gets its own + * namespace, keyed by user id, plus one shared "guest" namespace for work done + * while signed out. + * + * Nothing is ever deleted on an account switch. Signing out and back in returns + * you to exactly what you left. + * + * Note that guest work is genuinely shared: two people using the same browser + * without signing in are indistinguishable, so they see the same diagrams. + * That is unavoidable, and signing in is what separates them. + * + * Diagrams live in IndexedDB rather than localStorage. localStorage caps an + * origin at roughly 5MB, which several accounts' diagrams share (and a diagram + * carries a full snapshot per AI chat turn, so they are not small). Worse, that + * same 5MB holds the auth token, so filling it could break signing in. + * IndexedDB is measured in gigabytes. localStorage remains the fallback for + * browsers where IndexedDB can't be opened. + */ + +/** Namespace for work done while signed out. */ +export const GUEST_SCOPE = 'guest'; + +/** A storage namespace: a user id, or GUEST_SCOPE. */ +export type StoreScope = string; + +/** + * Guest keeps the original unprefixed names so that local work predating any of + * this is still found and carried forward. + */ +const BASE_KEYS = { + graphs: 'econgraph_graphs', + projects: 'econgraph_projects', +} as const; + +type Collection = keyof typeof BASE_KEYS; +const COLLECTIONS = Object.keys(BASE_KEYS) as Collection[]; + +/** Pre-namespacing key recording which account the shared store belonged to. */ +const LEGACY_OWNER_KEY = 'econgraph_owner'; +const VERSION_KEY = 'econgraph_store_version'; +const VERSION_NAMESPACED = '2'; // per-account, still in localStorage +const VERSION_INDEXEDDB = '3'; // per-account, moved to IndexedDB + +const DB_NAME = 'econgraph'; +const DB_VERSION = 1; +const DB_STORE = 'scopes'; +/** Give up and fall back rather than hanging the app behind a stuck open(). */ +const DB_OPEN_TIMEOUT_MS = 4000; + +function localKey(collection: Collection, scope: StoreScope): string { + const base = BASE_KEYS[collection]; + return scope === GUEST_SCOPE ? base : `${base}__u_${scope}`; +} + +function dbKey(collection: Collection, scope: StoreScope): string { + return `${scope}::${collection}`; +} + +// --------------------------------------------------------------------------- +// localStorage backend (also the source for the one-time move into IndexedDB) +// --------------------------------------------------------------------------- + +/** + * `ok` is false only when localStorage itself refused the read (disabled, or a + * SecurityError in a partitioned context). A missing key is a successful read of + * nothing, and the two must not be confused: one means the namespace is empty, + * the other means we have no idea what is in it. + */ +function lsGet(collection: Collection, scope: StoreScope): { ok: boolean; raw: string | null } { + try { + return { ok: true, raw: localStorage.getItem(localKey(collection, scope)) }; + } catch { + return { ok: false, raw: null }; + } +} + +/** Returns whether the write actually landed. Callers that then clear a source + * namespace MUST check this, or a failed write silently destroys the data. */ +function lsSet(collection: Collection, scope: StoreScope, raw: string | null): boolean { + try { + const key = localKey(collection, scope); + if (raw === null) localStorage.removeItem(key); + else localStorage.setItem(key, raw); + return true; + } catch (e) { + console.error(`Failed to write ${collection} for scope ${scope}:`, e); + return false; + } +} + +function parseArray(raw: string | null): T[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// IndexedDB backend +// --------------------------------------------------------------------------- + +function openDb(): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (db: IDBDatabase | null) => { + if (settled) return; + settled = true; + resolve(db); + }; + try { + if (typeof indexedDB === 'undefined') return done(null); + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(DB_STORE)) db.createObjectStore(DB_STORE); + }; + req.onsuccess = () => done(req.result); + req.onerror = () => done(null); + // Another tab is mid-upgrade and holding the database. + req.onblocked = () => done(null); + setTimeout(() => done(null), DB_OPEN_TIMEOUT_MS); + } catch { + done(null); + } + }); +} + +/** + * `ok` distinguishes a genuine failure from a successful read that happens to + * return nothing. Writes are only safe to act on when `ok` is true: treating a + * failed write as success is how a move loses data. + */ +interface IdbResult { ok: boolean; value: T | null } + +function idbRequest(db: IDBDatabase, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise> { + return new Promise((resolve) => { + try { + const tx = db.transaction(DB_STORE, mode); + const req = run(tx.objectStore(DB_STORE)); + // Resolve on transaction completion for writes: a request can + // succeed and the transaction still abort (quota, for one). + req.onsuccess = () => { + if (mode === 'readonly') resolve({ ok: true, value: req.result as T }); + }; + tx.oncomplete = () => resolve({ ok: true, value: (req.result ?? null) as T | null }); + req.onerror = () => resolve({ ok: false, value: null }); + tx.onabort = () => resolve({ ok: false, value: null }); + tx.onerror = () => resolve({ ok: false, value: null }); + } catch (e) { + console.error('IndexedDB operation failed:', e); + resolve({ ok: false, value: null }); + } + }); +} + +const idbGet = (db: IDBDatabase, key: string) => idbRequest(db, 'readonly', (s) => s.get(key)); +const idbPut = (db: IDBDatabase, key: string, value: unknown) => idbRequest(db, 'readwrite', (s) => s.put(value, key)); +const idbDelete = (db: IDBDatabase, key: string) => idbRequest(db, 'readwrite', (s) => s.delete(key)); + +// --------------------------------------------------------------------------- +// Initialisation and migration +// --------------------------------------------------------------------------- + +let db: IDBDatabase | null = null; +let readyPromise: Promise | null = null; + +function readVersion(): string | null { + try { + return localStorage.getItem(VERSION_KEY); + } catch { + return null; + } +} + +function writeVersion(version: string): void { + try { + localStorage.setItem(VERSION_KEY, version); + } catch { /* ignore */ } +} + +/** + * Split the old shared store into per-account namespaces (still localStorage). + * + * Before this, everyone's diagrams shared one set of keys and `econgraph_owner` + * recorded who they belonged to. If an account owned them, they move into that + * account's namespace so signing in still finds them. If nothing owned them, + * they were anonymous and already live where guest work belongs. + */ +function migrateToNamespaces(): void { + let owner: string | null = null; + try { + owner = localStorage.getItem(LEGACY_OWNER_KEY); + } catch { /* ignore */ } + + if (owner && owner !== GUEST_SCOPE) { + // A collection left behind still belongs to `owner`, but it is sitting + // in the guest keys. Stamping the version would end the retries and + // leave it looking like work done signed out, so it would vanish from + // the account it actually belongs to. Keep the owner key and the + // version as they are until the whole move lands. + let complete = true; + for (const collection of COLLECTIONS) { + const source = lsGet(collection, GUEST_SCOPE); + const destination = lsGet(collection, owner); + if (!source.ok || !destination.ok) { complete = false; continue; } + if (source.raw === null) continue; + + if (destination.raw === null) { + // Only drop the source once the copy is definitely on disk. + if (!lsSet(collection, owner, source.raw)) { complete = false; continue; } + } else if (destination.raw !== source.raw) { + // The account already has its own copy of this collection and it + // is not the one we are holding. Never clobber it, and leave the + // guest data alone rather than guess which is wanted. + continue; + } + // The account namespace now holds exactly this content, so the guest + // copy is a duplicate. Clearing it is what finishes the move: a + // failed clear leaves the same diagrams visible in both namespaces, + // so it counts as unfinished and gets retried on the next load. + // This is also the path a resumed run takes, where the copy landed + // last time but the clear did not. + if (!lsSet(collection, GUEST_SCOPE, null)) complete = false; + } + if (!complete) return; + try { localStorage.removeItem(LEGACY_OWNER_KEY); } catch { /* ignore */ } + } + writeVersion(VERSION_NAMESPACED); +} + +/** + * Move every namespace out of localStorage and into IndexedDB, freeing the + * origin's 5MB budget. Scans for any `econgraph_graphs*` / `econgraph_projects*` + * key so it catches guest and every account in one pass. + */ +async function migrateToIndexedDb(database: IDBDatabase): Promise { + let keys: string[] = []; + try { + keys = Object.keys(localStorage); + } catch { + return; + } + + // Every namespace left behind has to be retried on the next load. Stamping + // the version after a partial run would end the retries while reads have + // already switched to IndexedDB, so the skipped namespace would sit in + // localStorage that nothing ever looks at again. + let complete = true; + for (const collection of COLLECTIONS) { + const base = BASE_KEYS[collection]; + for (const key of keys) { + if (key !== base && !key.startsWith(`${base}__u_`)) continue; + const scope = key === base ? GUEST_SCOPE : key.slice(`${base}__u_`.length); + let raw: string | null = null; + try { raw = localStorage.getItem(key); } catch { complete = false; continue; } + if (raw === null) continue; + + const existing = await idbGet(database, dbKey(collection, scope)); + if (!existing.ok) { complete = false; continue; } // can't tell what's there; leave the source alone + // Only seed a namespace IndexedDB doesn't already know about, so a + // partially completed run can be repeated safely. + if (!Array.isArray(existing.value)) { + const written = await idbPut(database, dbKey(collection, scope), parseArray(raw)); + // Keep localStorage as the copy of record until the move lands. + if (!written.ok) { complete = false; continue; } + } + try { localStorage.removeItem(key); } catch { /* ignore */ } + } + } + if (complete) writeVersion(VERSION_INDEXEDDB); +} + +async function init(): Promise { + if (readVersion() !== VERSION_NAMESPACED && readVersion() !== VERSION_INDEXEDDB) { + migrateToNamespaces(); + } + db = await openDb(); + if (db && readVersion() !== VERSION_INDEXEDDB) { + await migrateToIndexedDb(db); + } +} + +/** + * Every public call funnels through this, so callers never have to think about + * ordering: a read issued before initialisation finishes simply waits for it. + */ +function ready(): Promise { + if (!readyPromise) { + readyPromise = init().catch((e) => { + // Fall back to localStorage rather than leaving the app unable to + // load anything at all. + console.error('Local store initialisation failed, using localStorage:', e); + db = null; + }); + } + return readyPromise; +} + +/** Start opening the database. Optional: any read awaits this anyway. */ +export function initLocalStore(): Promise { + return ready(); +} + +/** + * Ask the browser not to evict this origin's data when disk runs low. Purely + * advisory, and unrelated to the quota itself. + */ +export async function requestPersistentStorage(): Promise { + try { + if (!navigator.storage?.persist) return false; + if (await navigator.storage.persisted()) return true; + return await navigator.storage.persist(); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Reads and writes +// --------------------------------------------------------------------------- + +/** + * `ok` has to reach the caller. A read that failed and a namespace that is + * genuinely empty both produce no items, but only one of them means it is safe + * to write over what is stored, and the caller is the only one who can tell the + * difference in a useful way. + */ +async function readCollection(collection: Collection, scope: StoreScope): Promise<{ ok: boolean; items: T[] }> { + await ready(); + if (db) { + const result = await idbGet(db, dbKey(collection, scope)); + return { ok: result.ok, items: Array.isArray(result.value) ? result.value : [] }; + } + const stored = lsGet(collection, scope); + return { ok: stored.ok, items: parseArray(stored.raw) }; +} + +// Serialise writes per key: two rapid saves resolving out of order would +// otherwise leave the older array on disk. +const writeQueues = new Map>(); + +function enqueueWrite(key: string, op: () => Promise): Promise { + const previous = writeQueues.get(key) ?? Promise.resolve(true); + const next = previous.then(op, op).catch((e) => { + console.error(`Failed to save ${key}:`, e); + return false; + }); + writeQueues.set(key, next); + return next; +} + +/** Resolves to whether the data is actually stored. */ +async function writeCollection(collection: Collection, scope: StoreScope, items: T[]): Promise { + await ready(); + const key = dbKey(collection, scope); + return enqueueWrite(key, async () => { + if (db) return (await idbPut(db, key, items)).ok; + return lsSet(collection, scope, JSON.stringify(items)); + }); +} + +/** + * Read one account's (or the guest's) stored diagrams and projects. + * + * `ok` is false when the store could not be read. The arrays are empty then + * too, but the caller must not act on that: treating an unreadable namespace as + * an empty one is how a library gets overwritten with nothing. + */ +export async function readScope(scope: StoreScope): Promise<{ ok: boolean; graphs: Graph[]; projects: Project[] }> { + const [graphs, projects] = await Promise.all([ + readCollection('graphs', scope), + readCollection('projects', scope), + ]); + return { ok: graphs.ok && projects.ok, graphs: graphs.items, projects: projects.items }; +} + +export function writeGraphs(scope: StoreScope, graphs: Graph[]): Promise { + return writeCollection('graphs', scope, graphs); +} + +export function writeProjects(scope: StoreScope, projects: Project[]): Promise { + return writeCollection('projects', scope, projects); +} + +/** + * Whether a namespace holds anything worth keeping. False if it could not be + * read: the caller uses this to decide whether guest work is waiting to be + * adopted, and "we don't know" must not start a move. + */ +export async function scopeHasContent(scope: StoreScope): Promise { + const { ok, graphs, projects } = await readScope(scope); + return ok && (graphs.length > 0 || projects.length > 0); +} + +/** + * What to do with work done signed out, once someone signs in. + * + * - `wait` nothing to decide yet, or we can't tell whether the + * account is empty until its first cloud pull lands. + * - `adopt` the account has nothing of its own, so the signed-out work + * becomes theirs. + * - `keep-separate` the account already has diagrams. Never merge the two: + * the signed-out work stays where it is and is still there + * when they sign out again. + */ +export type AdoptionDecision = 'wait' | 'adopt' | 'keep-separate'; + +export function decideGuestAdoption(input: { + /** Signed in over guest work, with no diagrams of their own at load time. */ + pending: boolean; + /** The namespace in memory is the one we last loaded (no swap in flight). */ + scopeReady: boolean; + /** A Supporter whose first cloud pull hasn't landed yet. */ + awaitingFirstPull: boolean; + /** + * A Supporter whose first pull failed (error/offline). An empty account is + * then unproven: the cloud may well hold diagrams we simply couldn't read. + */ + firstPullFailed: boolean; + /** Whether the account has any diagrams or projects right now. */ + accountHasContent: boolean; +}): AdoptionDecision { + if (!input.pending || !input.scopeReady) return 'wait'; + if (input.awaitingFirstPull) return 'wait'; + // Never treat "we couldn't reach the cloud" as "the account is empty": + // adopting on that basis mixes signed-out work into someone's real library. + if (input.firstPullFailed) return 'wait'; + return input.accountHasContent ? 'keep-separate' : 'adopt'; +} + +/** + * Hand a namespace's contents over to another one, emptying the source. + * + * Used when a signed-in account takes ownership of work done while signed out. + * The caller must have established that the destination is empty: this + * overwrites rather than merges, precisely so two people's diagrams are never + * silently mixed together. + * + * Returns null if the copy did not land, leaving the source untouched. Clearing + * the source on a failed write would destroy the only copy, which is the whole + * thing this namespacing exists to prevent. + */ +export async function adoptScope(from: StoreScope, to: StoreScope): Promise<{ graphs: Graph[]; projects: Project[] } | null> { + const moved = await readScope(from); + // Copying nothing and then clearing the source would empty the namespace we + // were asked to preserve, which is the one outcome this must never produce. + if (!moved.ok) { + console.error(`Could not read ${from}; leaving it in place rather than moving an unknown amount of data.`); + return null; + } + const [graphsSaved, projectsSaved] = await Promise.all([ + writeGraphs(to, moved.graphs), + writeProjects(to, moved.projects), + ]); + if (!graphsSaved || !projectsSaved) { + console.error(`Could not move ${from} into ${to}; leaving the source in place.`); + return null; + } + + await Promise.all([writeGraphs(from, []), writeProjects(from, [])]); + if (db) { + // Leave no empty records behind for a namespace nobody is using. + await Promise.all([ + idbDelete(db, dbKey('graphs', from)), + idbDelete(db, dbKey('projects', from)), + ]); + } + return moved; +} diff --git a/services/openrouter.ts b/services/openrouter.ts index 5b68dd1..55e0a81 100644 --- a/services/openrouter.ts +++ b/services/openrouter.ts @@ -1,32 +1,23 @@ import { DiagramData } from '../types'; +import { obfuscateKey, deobfuscateKey } from './keyObfuscation'; +import { OPENROUTER_SYSTEM_INSTRUCTION, buildHistoryContext } from './diagramPrompt'; const STORAGE_KEY = 'econgraph_openrouter_api_key'; const MODEL_STORAGE_KEY = 'econgraph_openrouter_selected_model'; -const OBFUSCATION_PREFIX = 'egk_'; - -function obfuscate(key: string): string { - return OBFUSCATION_PREFIX + btoa(key); -} - -function deobfuscate(stored: string): string { - if (!stored.startsWith(OBFUSCATION_PREFIX)) return stored; - return atob(stored.slice(OBFUSCATION_PREFIX.length)); -} - export function saveOpenRouterApiKey(key: string): void { if (!key.trim()) { localStorage.removeItem(STORAGE_KEY); return; } - localStorage.setItem(STORAGE_KEY, obfuscate(key.trim())); + localStorage.setItem(STORAGE_KEY, obfuscateKey(key.trim())); } export function getOpenRouterApiKey(): string { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return ''; try { - return deobfuscate(stored); + return deobfuscateKey(stored); } catch { return ''; } @@ -262,34 +253,8 @@ export async function generateDiagramDataOpenRouter(prompt: string, history: str throw new Error('No OpenRouter model selected. Please choose a model in Settings before using OpenRouter.'); } - const historyContext = history.length > 0 - ? `Previous context:\n${history.join('\n')}\n\nCurrent Request:` - : 'Request:'; - - const systemInstruction = ` -You are an expert Economics Professor and SVG Graph Generator. -Your goal is to generate precise coordinate data for economic diagrams based on user prompts. - -Rules for generation: -1. Coordinate System: Use a logical scale (e.g., 0-10 or 0-100). Keep it consistent. -2. Accuracy: Calculate intersection points mathematically. If Supply is P = 10 + Q and Demand is P = 100 - Q, Equilibrium is Q=45, P=55. -3. Shared Coordinates (CRITICAL): - - If an equilibrium point E is at (50, 50), ensuring the Supply Curve, Demand Curve, and any Shaded Regions ALL use the exact coordinate (50, 50). - - Do not approximate. If a shaded region (e.g., Consumer Surplus) is bounded by the Price axis, Demand curve, and Equilibrium price, the vertices must strictly match the curve points. -4. Shading: - - Provide a closed polygon for shaded areas. -5. Labels: - - Use LaTeX-style formatting for subscripts and superscripts. - - Example: "P_1", "Q^*", "Q_{tax}", "D_{private}". -6. Context: - - If the user asks for "Monopoly", ensure MR is below D. - - If the user asks for "Tax", shift the appropriate curve. - -Output requirements (STRICT): -- Output ONLY a JSON object (no prose). -- Do NOT wrap in markdown. -- The JSON must match the DiagramData shape used by this app: { title, summary, xAxis, yAxis, curves, annotatedPoints, shadedRegions }. -`; + const historyContext = buildHistoryContext(history); + const systemInstruction = OPENROUTER_SYSTEM_INSTRUCTION; const baseBody: any = { model, diff --git a/services/shares.ts b/services/shares.ts new file mode 100644 index 0000000..488d99d --- /dev/null +++ b/services/shares.ts @@ -0,0 +1,207 @@ +import { supabase } from './supabaseClient'; +import { DiagramData, Graph, Project } from '../types'; +import { isRlsDenied } from './cloudErrors'; + +export interface SharedGraphEntry { + id: string; + title: string; + caption?: string; + diagramData: DiagramData; +} + +export interface GraphSharePayload { + kind: 'graph'; + title: string; + caption?: string; + diagramData: DiagramData; +} + +export interface ProjectSharePayload { + kind: 'project'; + name: string; + graphs: SharedGraphEntry[]; +} + +export type SharePayload = GraphSharePayload | ProjectSharePayload; + +export function shareUrl(shareId: string): string { + return `${window.location.origin}/s/${shareId}`; +} + +/** 24 hex chars (96 bits) — unguessable slug. */ +export function newShareSlug(): string { + const bytes = new Uint8Array(12); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** Shares never include chat history — diagram content only. */ +export function graphSharePayload(graph: Graph): GraphSharePayload { + return { + kind: 'graph', + title: graph.diagramData.title || graph.title, + caption: graph.caption || graph.diagramData.caption, + diagramData: graph.diagramData, + }; +} + +export function projectSharePayload(project: Project, graphs: Graph[]): ProjectSharePayload { + return { + kind: 'project', + name: project.name, + graphs: graphs + .filter((g) => g.projectId === project.id) + .map((g) => ({ + id: g.id, + title: g.diagramData.title || g.title, + caption: g.caption || g.diagramData.caption, + diagramData: g.diagramData, + })), + }; +} + +/** Postgres unique_violation — the one-share-per-content indexes fired. */ +function isDuplicateShare(error: { code?: string; message?: string }): boolean { + return error.code === '23505' || /duplicate key value/i.test(error.message ?? ''); +} + +/** + * Look up the existing share for a piece of content, keeping "none exists" + * distinct from "the lookup failed". Callers that mint a new slug MUST NOT + * treat a failure as "none": that would create a second share row for the same + * content, and revoking the one the UI shows would leave the other link live. + */ +async function findShareId( + kind: 'graph' | 'project', + column: 'graph_id' | 'project_id', + contentId: string, +): Promise<{ id: string | null; failed: boolean }> { + if (!supabase) return { id: null, failed: true }; + const { data, error } = await supabase + .from('shares') + .select('id') + .eq('kind', kind) + .eq(column, contentId) + .limit(1) + .maybeSingle(); + if (error) return { id: null, failed: true }; + return { id: data?.id ?? null, failed: false }; +} + +export async function getShareIdForGraph(graphId: string): Promise { + if (!supabase) return null; + const { data } = await supabase + .from('shares') + .select('id') + .eq('kind', 'graph') + .eq('graph_id', graphId) + .limit(1) + .maybeSingle(); + return data?.id ?? null; +} + +export async function getShareIdForProject(projectId: string): Promise { + if (!supabase) return null; + const { data } = await supabase + .from('shares') + .select('id') + .eq('kind', 'project') + .eq('project_id', projectId) + .limit(1) + .maybeSingle(); + return data?.id ?? null; +} + +export async function createOrUpdateGraphShare(userId: string, graph: Graph): Promise<{ id?: string; error?: string }> { + if (!supabase) return { error: 'Sharing is not available on this deployment.' }; + const existing = await findShareId('graph', 'graph_id', graph.id); + if (existing.failed) { + return { error: 'Could not check for an existing link right now. Please try again in a moment.' }; + } + const id = existing.id ?? newShareSlug(); + const { error } = await supabase.from('shares').upsert({ + id, + user_id: userId, + kind: 'graph', + graph_id: graph.id, + project_id: null, + payload: graphSharePayload(graph), + updated_at: new Date().toISOString(), + }); + if (error) { + // Lost a race: another tab created the link between our lookup and this + // insert, and the one-share-per-graph index rejected the second slug. + // Hand back the link that won rather than surfacing a database error. + if (isDuplicateShare(error)) { + const winner = await findShareId('graph', 'graph_id', graph.id); + if (winner.id) return { id: winner.id }; + } + return { error: friendlyShareError(error.message) }; + } + return { id }; +} + +export async function createOrUpdateProjectShare( + userId: string, + project: Project, + graphs: Graph[], +): Promise<{ id?: string; error?: string }> { + if (!supabase) return { error: 'Sharing is not available on this deployment.' }; + const existing = await findShareId('project', 'project_id', project.id); + if (existing.failed) { + return { error: 'Could not check for an existing link right now. Please try again in a moment.' }; + } + const id = existing.id ?? newShareSlug(); + const { error } = await supabase.from('shares').upsert({ + id, + user_id: userId, + kind: 'project', + graph_id: null, + project_id: project.id, + payload: projectSharePayload(project, graphs), + updated_at: new Date().toISOString(), + }); + if (error) { + if (isDuplicateShare(error)) { + const winner = await findShareId('project', 'project_id', project.id); + if (winner.id) return { id: winner.id }; + } + return { error: friendlyShareError(error.message) }; + } + return { id }; +} + +export async function revokeShare(shareId: string): Promise<{ error?: string }> { + if (!supabase) return { error: 'Sharing is not available on this deployment.' }; + // Ask for the deleted row back. The delete policy is scoped to the owner, so + // an id that doesn't match (or an RLS denial) removes nothing and still + // reports success. Telling someone their link is revoked while it keeps + // resolving is the worst possible outcome here. + const { data, error } = await supabase.from('shares').delete().eq('id', shareId).select('id'); + if (error) return { error: error.message }; + if (!data || data.length === 0) { + return { error: 'Could not revoke that link. Please reload and try again.' }; + } + return {}; +} + +/** + * Public fetch — works without a session (anyone with the link). Reads through + * the get_share() RPC so the shares table stays non-enumerable by anon. + * Throws on transport/database errors so callers can distinguish "not found" + * (null) from "couldn't load" (throw). + */ +export async function fetchSharedPayload(slug: string): Promise { + if (!supabase) return null; + const { data, error } = await supabase.rpc('get_share', { p_id: slug }); + if (error) throw new Error(error.message); + if (!data) return null; + return data as SharePayload; +} + +function friendlyShareError(message: string): string { + if (isRlsDenied(message)) { + return 'Sharing links are part of the Supporter plan.'; + } + return message; +} diff --git a/services/supabaseClient.ts b/services/supabaseClient.ts new file mode 100644 index 0000000..65f2fe9 --- /dev/null +++ b/services/supabaseClient.ts @@ -0,0 +1,28 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; + +// The app is fully functional without Supabase — accounts, sync, sharing and +// hosted AI simply stay hidden. This keeps self-hosted/forked deployments +// zero-config. +const url = import.meta.env.VITE_SUPABASE_URL; +// Supabase publishable key (`sb_publishable_…`), the modern replacement for the +// legacy anon key. Low-privilege and safe to ship in the client bundle. +const publishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; + +export const supabase: SupabaseClient | null = + url && publishableKey + ? createClient(url, publishableKey, { + auth: { + persistSession: true, + autoRefreshToken: true, + detectSessionInUrl: true, + }, + }) + : null; + +export const isCloudConfigured = supabase !== null; + +export async function getAccessToken(): Promise { + if (!supabase) return null; + const { data } = await supabase.auth.getSession(); + return data.session?.access_token ?? null; +} diff --git a/services/sync.ts b/services/sync.ts new file mode 100644 index 0000000..1f5ccc9 --- /dev/null +++ b/services/sync.ts @@ -0,0 +1,729 @@ +import { supabase } from './supabaseClient'; +import { graphSharePayload, projectSharePayload } from './shares'; +import { Graph, Project } from '../types'; +import { isRlsDenied } from './cloudErrors'; + +// ───────────────────────────────────────────────────────────────────────────── +// Local-first cloud sync (Supporter feature). +// +// localStorage remains the working store; this module reconciles it with +// Supabase using last-write-wins on the client's `lastModified` timestamps. +// Deletions are tracked with tombstones on both sides so a delete on one +// device doesn't get resurrected by a stale copy on another. +// ───────────────────────────────────────────────────────────────────────────── + +const TOMBSTONE_KEY = 'econgraph_tombstones_v1'; +const TOMBSTONE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; +const VERSIONS_TO_KEEP = 30; + +// Content hash of each graph's last version snapshot, so we don't write a fresh +// full snapshot when only last_modified changed (rename, re-parenting, re-import, +// idempotent autosave). graph_versions is the fastest-growing table on the free +// tier, and these duplicates are pure waste. Per-device/best-effort: a cleared +// store just means one extra snapshot. +const VERSION_HASH_KEY = 'econgraph_version_hashes_v1'; + +// All three of these track work owed to one specific account. Two accounts +// sharing a browser must not share them: B's sync would find A's queued graph +// ids missing from its own library, conclude the graphs were deleted and drop +// them, and B's successful share refresh would clear the flag A is still +// waiting on. Either way A's retry never happens and the work is lost. +const perUser = (base: string, userId: string) => `${base}__u_${userId}`; + +function loadVersionHashes(userId: string): Record { + try { + const raw = localStorage.getItem(perUser(VERSION_HASH_KEY, userId)); + if (raw) return JSON.parse(raw) as Record; + } catch { /* corrupted — start fresh */ } + return {}; +} + +function saveVersionHashes(userId: string, map: Record): void { + try { + localStorage.setItem(perUser(VERSION_HASH_KEY, userId), JSON.stringify(map)); + } catch { /* quota — best-effort */ } +} + +// Graphs whose snapshot insert failed, and shares whose refresh failed. Both +// are best-effort steps that run only for rows touched by the current sync, so +// without a record of the failure a later sync that happens to touch nothing +// would never retry them and the work would be lost for good. +const PENDING_VERSIONS_KEY = 'econgraph_pending_versions_v1'; +const PENDING_SHARES_KEY = 'econgraph_pending_share_refresh_v1'; + +function loadPendingVersionIds(userId: string): Set { + try { + const raw = localStorage.getItem(perUser(PENDING_VERSIONS_KEY, userId)); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return new Set(parsed.filter((v) => typeof v === 'string')); + } + } catch { /* corrupted — start fresh */ } + return new Set(); +} + +function savePendingVersionIds(userId: string, ids: Set): void { + try { + const key = perUser(PENDING_VERSIONS_KEY, userId); + if (ids.size === 0) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify([...ids])); + } catch { /* quota — best-effort */ } +} + +function sharesRefreshPending(userId: string): boolean { + try { + return localStorage.getItem(perUser(PENDING_SHARES_KEY, userId)) === '1'; + } catch { + return false; + } +} + +function setSharesRefreshPending(userId: string, pending: boolean): void { + try { + const key = perUser(PENDING_SHARES_KEY, userId); + if (pending) localStorage.setItem(key, '1'); + else localStorage.removeItem(key); + } catch { /* quota — best-effort */ } +} + +/** Small, fast, non-cryptographic content hash (djb2). Collisions only cost a + * skipped snapshot, so a cheap hash is fine here. */ +function contentHash(s: string): string { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h.toString(36); +} + +/** + * Fingerprint of the parts a version snapshot exists to preserve. Hashing the + * whole `Graph` defeated the dedup entirely: it includes `lastModified`, which + * every autosave rewrites, so no push ever compared equal and a duplicate + * snapshot was written on each sync. + */ +function versionFingerprint(g: Graph): string { + return contentHash(JSON.stringify({ + diagramData: g.diagramData, + title: g.title, + caption: g.caption, + })); +} + +interface TombstoneStore { + graphs: Record; + projects: Record; +} + +function loadTombstones(): TombstoneStore { + try { + const raw = localStorage.getItem(TOMBSTONE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return { + graphs: parsed.graphs ?? {}, + projects: parsed.projects ?? {}, + }; + } + } catch { /* corrupted store — start fresh */ } + return { graphs: {}, projects: {} }; +} + +function saveTombstones(store: TombstoneStore): void { + const cutoff = Date.now() - TOMBSTONE_MAX_AGE_MS; + for (const kind of ['graphs', 'projects'] as const) { + for (const [id, ts] of Object.entries(store[kind])) { + if (ts < cutoff) delete store[kind][id]; + } + } + try { + localStorage.setItem(TOMBSTONE_KEY, JSON.stringify(store)); + } catch { /* quota — tombstones are best-effort */ } +} + +/** Call whenever graphs/projects are deleted locally so sync can propagate it. */ +export function recordTombstones(kind: 'graphs' | 'projects', ids: string[]): void { + if (ids.length === 0) return; + const store = loadTombstones(); + const now = Date.now(); + for (const id of ids) store[kind][id] = now; + saveTombstones(store); +} + +/** + * Remove tombstones for the given ids (e.g. when a backup import restores them), + * so a live row and a tombstone for the same id are never queued together. + */ +export function clearTombstones(kind: 'graphs' | 'projects', ids: string[]): void { + if (ids.length === 0) return; + const store = loadTombstones(); + let changed = false; + for (const id of ids) { + if (store[kind][id] !== undefined) { + delete store[kind][id]; + changed = true; + } + } + if (changed) saveTombstones(store); +} + +/** + * Fetch the ids of the signed-in user's live (non-deleted) cloud graphs and + * projects. Backup restore uses this so "replace everything" can also tombstone + * cloud rows that exist only on another device and were never pulled here — + * otherwise the next sync would resurrect them. RLS scopes the result to the + * caller's own rows. Returns null when cloud is unavailable (offline / not + * configured / not signed in), in which case the local-only behaviour applies. + */ +export async function fetchCloudIds(): Promise<{ graphIds: string[]; projectIds: string[] } | null> { + if (!supabase) return null; + try { + const [graphsRes, projectsRes] = await Promise.all([ + supabase.from('graphs').select('id').eq('deleted', false), + supabase.from('projects').select('id').eq('deleted', false), + ]); + if (graphsRes.error || projectsRes.error) return null; + return { + graphIds: (graphsRes.data ?? []).map((r) => (r as { id: string }).id), + projectIds: (projectsRes.data ?? []).map((r) => (r as { id: string }).id), + }; + } catch { + return null; + } +} + +// ── Remote row shapes ──────────────────────────────────────────────────────── + +interface RemoteGraphRow { + id: string; + user_id?: string; + project_id: string | null; + title: string; + data: Graph | Record; + created_at_ms: number; + last_modified: number; + deleted: boolean; +} + +/** + * A graph row without its `data` blob. Reconciliation only needs the + * timestamps and flags to decide what to do; `data` is fetched afterwards for + * the handful of rows actually being pulled. + */ +type RemoteGraphMeta = Omit; + +/** Columns that decide reconciliation. Everything here is a few bytes per row. */ +const GRAPH_META_COLUMNS = 'id, project_id, title, created_at_ms, last_modified, deleted'; + +/** + * PostgREST puts filters in the query string, so a single `in.(…)` list of + * UUIDs has to stay under the server's URL length limit. 100 ids is ~3.7 KB, + * comfortably inside it, and a first sync pulling thousands of graphs just + * issues a few requests. + */ +const PULL_CHUNK_SIZE = 100; + +/** + * Fetch the `data` blobs for exactly the graphs being pulled. + * + * The alternative, selecting `data` for the whole library in the reconciliation + * query, downloaded every diagram the user owns on every sync, including the + * overwhelmingly common case where nothing changed at all. Supabase's free tier + * is metered on egress rather than request count, so trading one large response + * for a small one plus an occasional second round trip is a large saving and + * costs nothing measurable. + */ +async function fetchGraphData(ids: string[]): Promise>> { + const out = new Map>(); + if (!supabase || ids.length === 0) return out; + for (let i = 0; i < ids.length; i += PULL_CHUNK_SIZE) { + const chunk = ids.slice(i, i + PULL_CHUNK_SIZE); + const { data, error } = await supabase.from('graphs').select('id, data').in('id', chunk); + if (error) throw new Error(friendlySyncError(error.message)); + for (const row of (data ?? []) as { id: string; data: Graph | Record }[]) { + out.set(row.id, row.data); + } + } + return out; +} + +interface RemoteProjectRow { + id: string; + user_id?: string; + name: string; + description: string; + color: string; + created_at_ms: number; + last_modified: number; + deleted: boolean; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(id: string): boolean { + return UUID_RE.test(id); +} + +/** + * Remote ids are uuid columns; data imported from very old backups may have + * non-uuid ids. Remap them (and graph→project references) before syncing. + */ +export function remapNonUuidIds(graphs: Graph[], projects: Project[]): { + graphs: Graph[]; projects: Project[]; changed: boolean; +} { + let changed = false; + const projectIdMap = new Map(); + + const newProjects = projects.map((p) => { + if (isUuid(p.id)) return p; + changed = true; + const newId = crypto.randomUUID(); + projectIdMap.set(p.id, newId); + return { ...p, id: newId }; + }); + + const newGraphs = graphs.map((g) => { + let next = g; + if (g.projectId && projectIdMap.has(g.projectId)) { + next = { ...next, projectId: projectIdMap.get(g.projectId) }; + changed = true; + } + if (!isUuid(next.id)) { + next = { ...next, id: crypto.randomUUID() }; + changed = true; + } + return next; + }); + + return { graphs: newGraphs, projects: newProjects, changed }; +} + +export interface SyncOutcome { + graphs: Graph[]; + projects: Project[]; + /** True when local state differs from what was passed in (apply it). */ + changedLocal: boolean; + pushed: number; + pulled: number; +} + +function graphToRow(g: Graph, userId: string): RemoteGraphRow { + return { + id: g.id, + user_id: userId, + project_id: g.projectId && isUuid(g.projectId) ? g.projectId : null, + title: g.diagramData?.title || g.title || '', + data: g, + created_at_ms: g.createdAt ?? 0, + last_modified: g.lastModified ?? 0, + deleted: false, + }; +} + +function projectToRow(p: Project, userId: string): RemoteProjectRow { + return { + id: p.id, + user_id: userId, + name: p.name, + description: p.description ?? '', + color: p.color ?? '#3b82f6', + created_at_ms: p.createdAt ?? 0, + last_modified: p.lastModified ?? 0, + deleted: false, + }; +} + +// Tombstone rows must carry the FULL column set for their table. postgrest-js +// upserts batch rows together and sends any key missing from a row as NULL, so +// a partial tombstone batched with a full alive row would write NULL into a +// NOT NULL column (e.g. created_at_ms) and the whole upsert fails. +function graphTombstoneRow(id: string, userId: string, deletedAt: number): RemoteGraphRow { + // Content is wiped on deletion. The graph's version history is removed by + // the graphs_purge_versions_on_delete trigger (see supabase/schema.sql), + // so it happens server-side no matter which client performed the delete. + return { + id, user_id: userId, project_id: null, title: '', data: {}, + created_at_ms: 0, last_modified: deletedAt, deleted: true, + }; +} + +function projectTombstoneRow(id: string, userId: string, deletedAt: number): RemoteProjectRow { + return { + id, user_id: userId, name: '', description: '', color: '#3b82f6', + created_at_ms: 0, last_modified: deletedAt, deleted: true, + }; +} + +/** + * Reconcile local graphs/projects with the cloud. Throws on hard failures + * (network, RLS) with a user-presentable message. + */ +export async function syncCloud(userId: string, localGraphsIn: Graph[], localProjectsIn: Project[]): Promise { + if (!supabase) throw new Error('Cloud sync is not available on this deployment.'); + + const remap = remapNonUuidIds(localGraphsIn, localProjectsIn); + const localGraphs = remap.graphs; + const localProjects = remap.projects; + let changedLocal = remap.changed; + + const tombs = loadTombstones(); + + // Graphs are fetched without their `data` blob; projects are fetched whole + // because their payload *is* the metadata (name, description, colour). + const [graphRes, projectRes] = await Promise.all([ + supabase.from('graphs').select(GRAPH_META_COLUMNS), + supabase.from('projects').select('id, name, description, color, created_at_ms, last_modified, deleted'), + ]); + if (graphRes.error) throw new Error(friendlySyncError(graphRes.error.message)); + if (projectRes.error) throw new Error(friendlySyncError(projectRes.error.message)); + + const remoteGraphs = (graphRes.data ?? []) as RemoteGraphMeta[]; + const remoteProjects = (projectRes.data ?? []) as RemoteProjectRow[]; + + let pushed = 0; + let pulled = 0; + + // ── Projects ── + const projectRows: RemoteProjectRow[] = []; + const projectTombRows: RemoteProjectRow[] = []; + const projectTombIds = new Set(); // guard against pushing an id twice (ON CONFLICT 21000) + const finalProjects = new Map(localProjects.map((p) => [p.id, p])); + const remoteProjectMap = new Map(remoteProjects.map((r) => [r.id, r])); + + for (const remote of remoteProjects) { + const local = finalProjects.get(remote.id); + if (remote.deleted) { + if (local) { + if ((local.lastModified ?? 0) > remote.last_modified) { + projectRows.push(projectToRow(local, userId)); // resurrect + } else { + finalProjects.delete(remote.id); + changedLocal = true; + } + } + delete tombs.projects[remote.id]; // server already knows + continue; + } + if (local) { + if (remote.last_modified > (local.lastModified ?? 0)) { + finalProjects.set(remote.id, { + id: remote.id, + name: remote.name, + description: remote.description, + color: remote.color, + createdAt: remote.created_at_ms, + lastModified: remote.last_modified, + }); + changedLocal = true; + pulled++; + } else if (remote.last_modified < (local.lastModified ?? 0)) { + projectRows.push(projectToRow(local, userId)); + } + } else { + const tombTs = tombs.projects[remote.id]; + if (tombTs && tombTs >= remote.last_modified) { + projectTombRows.push(projectTombstoneRow(remote.id, userId, tombTs)); + projectTombIds.add(remote.id); + } else { + finalProjects.set(remote.id, { + id: remote.id, + name: remote.name, + description: remote.description, + color: remote.color, + createdAt: remote.created_at_ms, + lastModified: remote.last_modified, + }); + changedLocal = true; + pulled++; + } + } + } + for (const local of finalProjects.values()) { + if (!remoteProjectMap.has(local.id)) { + projectRows.push(projectToRow(local, userId)); + } + } + // A tombstone for an id that is alive locally is stale: the row came back + // after the delete was recorded (pulled from another device, restored from + // a backup, resurrected above). Left in place it never expires, and if the + // timestamps line up the catch-all below queues a tombstone for an id this + // same batch is upserting as alive — which Postgres rejects with "ON + // CONFLICT DO UPDATE command cannot affect row a second time", failing the + // whole sync. + for (const id of finalProjects.keys()) delete tombs.projects[id]; + + // Tombstones for local deletions the server hasn't heard about yet. + for (const [id, ts] of Object.entries(tombs.projects)) { + if (projectTombIds.has(id)) continue; // already queued above + const remote = remoteProjectMap.get(id); + if (remote && !remote.deleted && remote.last_modified <= ts) { + projectTombRows.push(projectTombstoneRow(id, userId, ts)); + projectTombIds.add(id); + } + } + + // ── Graphs ── + const graphRows: RemoteGraphRow[] = []; + const graphTombRows: RemoteGraphRow[] = []; + const graphTombIds = new Set(); + const finalGraphs = new Map(localGraphs.map((g) => [g.id, g])); + const remoteGraphMap = new Map(remoteGraphs.map((r) => [r.id, r])); + + const remoteRowToGraph = (row: RemoteGraphRow): Graph | null => { + const data = row.data as Graph; + if (!data || typeof data !== 'object' || !data.diagramData) return null; + return { ...data, id: row.id, lastModified: row.last_modified }; + }; + + // Reconciliation runs on metadata alone and records which rows it wants; + // their `data` is fetched in one batch afterwards. A sync that finds nothing + // to pull therefore never asks for a single diagram payload. + const toPull: RemoteGraphMeta[] = []; + + for (const remote of remoteGraphs) { + const local = finalGraphs.get(remote.id); + if (remote.deleted) { + if (local) { + if ((local.lastModified ?? 0) > remote.last_modified) { + graphRows.push(graphToRow(local, userId)); // resurrect + } else { + finalGraphs.delete(remote.id); + changedLocal = true; + } + } + delete tombs.graphs[remote.id]; + continue; + } + if (local) { + if (remote.last_modified > (local.lastModified ?? 0)) { + toPull.push(remote); + } else if (remote.last_modified < (local.lastModified ?? 0)) { + graphRows.push(graphToRow(local, userId)); + } + } else { + const tombTs = tombs.graphs[remote.id]; + if (tombTs && tombTs >= remote.last_modified) { + graphTombRows.push(graphTombstoneRow(remote.id, userId, tombTs)); + graphTombIds.add(remote.id); + } else { + toPull.push(remote); + } + } + } + + // Must land before the local-only push scan and the tombstone sweep below, + // both of which read the finished `finalGraphs`. + if (toPull.length > 0) { + const blobs = await fetchGraphData(toPull.map((r) => r.id)); + for (const remote of toPull) { + const data = blobs.get(remote.id); + // Absent means the row was hard-deleted between the two queries; + // malformed means the blob is unusable. Either way, skip it rather + // than writing a broken graph into local state. + if (data === undefined) continue; + const pulledGraph = remoteRowToGraph({ ...remote, data }); + if (pulledGraph) { + finalGraphs.set(remote.id, pulledGraph); + changedLocal = true; + pulled++; + } + } + } + + for (const local of finalGraphs.values()) { + if (!remoteGraphMap.has(local.id)) { + graphRows.push(graphToRow(local, userId)); + } + } + // Same stale-tombstone sweep as for projects above. + for (const id of finalGraphs.keys()) delete tombs.graphs[id]; + + for (const [id, ts] of Object.entries(tombs.graphs)) { + if (graphTombIds.has(id)) continue; // already queued above + const remote = remoteGraphMap.get(id); + if (remote && !remote.deleted && remote.last_modified <= ts) { + graphTombRows.push(graphTombstoneRow(id, userId, ts)); + graphTombIds.add(id); + } + } + + // ── Push ── + const projectUpserts = [...projectRows, ...projectTombRows]; + if (projectUpserts.length > 0) { + const { error } = await supabase.from('projects').upsert(projectUpserts as never[]); + if (error) throw new Error(friendlySyncError(error.message)); + pushed += projectUpserts.length; + } + const graphUpserts = [...graphRows, ...graphTombRows]; + if (graphUpserts.length > 0) { + const { error } = await supabase.from('graphs').upsert(graphUpserts as never[]); + if (error) throw new Error(friendlySyncError(error.message)); + pushed += graphUpserts.length; + } + + saveTombstones(tombs); + + // ── Version snapshots for pushed (alive) graphs ── + // Retries first: a graph whose snapshot insert failed on an earlier sync is + // not necessarily pushed again (it needs no further edits), so without this + // its revision would be lost permanently. + const pendingVersionIds = loadPendingVersionIds(userId); + const versionCandidates = [...graphRows]; + const queuedIds = new Set(graphRows.map((r) => r.id)); + for (const id of pendingVersionIds) { + if (queuedIds.has(id)) continue; + const graph = finalGraphs.get(id); + if (graph) versionCandidates.push(graphToRow(graph, userId)); + else pendingVersionIds.delete(id); // graph is gone; nothing to snapshot + } + + if (versionCandidates.length > 0) { + // Only snapshot graphs whose content actually changed since their last + // version — skip pushes that merely bumped last_modified, so identical + // snapshots don't pile up in the free-tier DB. + const hashes = loadVersionHashes(userId); + const changedRows = versionCandidates.filter((row) => { + const h = versionFingerprint(row.data as Graph); + if (hashes[row.id] === h && !pendingVersionIds.has(row.id)) return false; + hashes[row.id] = h; + return true; + }); + if (changedRows.length > 0) { + // Sorted by graph id because this is one multi-row insert, so it is + // one transaction, and the per-row cap trigger takes a + // transaction-scoped advisory lock per graph as the rows go in. Two + // devices pushing an overlapping set in their own library order + // would take those locks in opposite orders and deadlock. Sorting + // gives every client the same order, so they queue instead. + const versionRows = [...changedRows] + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .map((row) => ({ + graph_id: row.id, + user_id: userId, + title: row.title, + data: row.data, + last_modified: row.last_modified, + })); + const { error } = await supabase.from('graph_versions').insert(versionRows as never[]); + if (error) { + // Queue for the next sync rather than dropping the revision. + for (const row of changedRows) pendingVersionIds.add(row.id); + } else { + for (const row of changedRows) pendingVersionIds.delete(row.id); + saveVersionHashes(userId, hashes); + // Independent per-graph prunes — run them concurrently instead of a + // serial round-trip each, which stalls the debounced sync path. + await Promise.all( + changedRows.map((row) => + supabase!.rpc('prune_graph_versions', { p_graph: row.id, p_keep: VERSIONS_TO_KEEP }), + ), + ); + } + } + } + savePendingVersionIds(userId, pendingVersionIds); + + // ── Keep share links fresh, drop shares of deleted content ── + await refreshShares( + userId, + finalGraphs, + finalProjects, + graphRows, + projectRows, + graphTombRows.map((r) => r.id), + projectTombRows.map((r) => r.id), + ); + + return { + graphs: Array.from(finalGraphs.values()), + projects: Array.from(finalProjects.values()), + changedLocal, + pushed, + pulled, + }; +} + +async function refreshShares( + userId: string, + finalGraphs: Map, + finalProjects: Map, + pushedGraphRows: RemoteGraphRow[], + pushedProjectRows: RemoteProjectRow[], + deletedGraphIds: string[], + deletedProjectIds: string[], +): Promise { + if (!supabase) return; + // A previous run failed partway. Its shares were never refreshed and the + // rows behind them may not change again, so this run refreshes everything + // rather than only what it happened to touch. + const retryAll = sharesRefreshPending(userId); + let failed = false; + try { + const { data: shares, error } = await supabase + .from('shares') + .select('id, kind, graph_id, project_id') + .eq('user_id', userId); + if (error) throw new Error(error.message); + if (!shares || shares.length === 0) { + setSharesRefreshPending(userId, false); + return; + } + + const pushedIds = new Set(pushedGraphRows.map((r) => r.id)); + const pushedProjectIds = new Set(pushedProjectRows.map((r) => r.id)); + const allGraphs = Array.from(finalGraphs.values()); + + // Each share touches a different row, so refresh them concurrently + // rather than one blocking round-trip after another. + await Promise.all(shares.map(async (share) => { + if (share.kind === 'graph' && share.graph_id) { + if (deletedGraphIds.includes(share.graph_id) || !finalGraphs.has(share.graph_id)) { + const { error: delErr } = await supabase!.from('shares').delete().eq('id', share.id); + if (delErr) failed = true; + } else if (retryAll || pushedIds.has(share.graph_id)) { + const graph = finalGraphs.get(share.graph_id)!; + const { error: upErr } = await supabase!.from('shares') + .update({ payload: graphSharePayload(graph), updated_at: new Date().toISOString() }) + .eq('id', share.id); + if (upErr) failed = true; + } + } else if (share.kind === 'project' && share.project_id) { + if (deletedProjectIds.includes(share.project_id) || !finalProjects.has(share.project_id)) { + const { error: delErr } = await supabase!.from('shares').delete().eq('id', share.id); + if (delErr) failed = true; + } else { + const project = finalProjects.get(share.project_id)!; + const memberPushed = allGraphs.some((g) => g.projectId === project.id && pushedIds.has(g.id)); + // A deleted member is no longer in `allGraphs`, so its id isn't in + // `pushedIds` — without this, deleting a diagram from a shared project + // would leave it in the publicly served payload. Any deletion this + // sync re-renders the payload (which now omits the deleted graphs). + const memberDeleted = deletedGraphIds.length > 0; + // The project row itself can change without any member changing + // (a rename, a new colour); the payload embeds the project name, + // so that has to re-render too. + const projectPushed = pushedProjectIds.has(project.id); + if (retryAll || memberPushed || memberDeleted || projectPushed) { + const { error: upErr } = await supabase!.from('shares') + .update({ payload: projectSharePayload(project, allGraphs), updated_at: new Date().toISOString() }) + .eq('id', share.id); + if (upErr) failed = true; + } + } + } + })); + } catch { + failed = true; + } + // Sticky until a run completes cleanly, so a transient failure cannot leave + // a public link showing stale content forever. + setSharesRefreshPending(userId, failed); +} + +function friendlySyncError(message: string): string { + if (isRlsDenied(message)) { + return 'Cloud sync is part of the Supporter plan. Your data is still saved locally in this browser.'; + } + if (/Failed to fetch|network/i.test(message)) { + return 'Could not reach the sync server. Your data is safe locally; sync will retry.'; + } + return `Sync failed: ${message}`; +} diff --git a/services/useCloudSync.ts b/services/useCloudSync.ts new file mode 100644 index 0000000..bdd6cfd --- /dev/null +++ b/services/useCloudSync.ts @@ -0,0 +1,164 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { syncCloud } from './sync'; +import { Graph, Project } from '../types'; + +export type SyncStatus = 'disabled' | 'idle' | 'syncing' | 'error' | 'offline'; + +export interface SyncState { + status: SyncStatus; + lastSyncedAt: number | null; + error: string | null; +} + +const DEBOUNCE_MS = 4000; +const FOCUS_SYNC_MIN_INTERVAL_MS = 60_000; + +interface UseCloudSyncOptions { + /** userId when signed in AND entitled to sync; null otherwise. */ + userId: string | null; + hasInitialized: boolean; + graphs: Graph[]; + projects: Project[]; + /** + * Hand merged cloud state back to the app. `userId` identifies the account + * the sync ran for, so a result that arrives after an account switch can be + * discarded rather than imported into whoever is signed in now. + */ + applyRemote: (graphs: Graph[], projects: Project[], userId: string) => void; +} + +/** + * Debounced, self-healing cloud sync loop. Local-first: never blocks the UI, + * never runs concurrently, re-queues itself when local state changes during + * a run (so remote merges never clobber in-flight edits). + */ +export function useCloudSync({ userId, hasInitialized, graphs, projects, applyRemote }: UseCloudSyncOptions): { + syncState: SyncState; + syncNow: () => void; +} { + const [syncState, setSyncState] = useState({ status: 'disabled', lastSyncedAt: null, error: null }); + + const graphsRef = useRef(graphs); + const projectsRef = useRef(projects); + graphsRef.current = graphs; + projectsRef.current = projects; + + const userIdRef = useRef(userId); + userIdRef.current = userId; + + const runningRef = useRef(false); + const rerunRef = useRef(false); + const timerRef = useRef(null); + const lastRunRef = useRef(0); + const applyRemoteRef = useRef(applyRemote); + applyRemoteRef.current = applyRemote; + + const runSync = useCallback(async () => { + const uid = userIdRef.current; + if (!uid) return; + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + setSyncState((s) => ({ ...s, status: 'offline' })); + return; + } + if (runningRef.current) { + rerunRef.current = true; + return; + } + runningRef.current = true; + setSyncState((s) => ({ ...s, status: 'syncing', error: null })); + + const startGraphs = graphsRef.current; + const startProjects = projectsRef.current; + + try { + const outcome = await syncCloud(uid, startGraphs, startProjects); + lastRunRef.current = Date.now(); + + const localMoved = graphsRef.current !== startGraphs || projectsRef.current !== startProjects; + if (outcome.changedLocal && !localMoved) { + applyRemoteRef.current(outcome.graphs, outcome.projects, uid); + } else if (outcome.changedLocal && localMoved) { + // Local state advanced while we were syncing — run again rather + // than applying a stale merge. + rerunRef.current = true; + } + setSyncState({ status: 'idle', lastSyncedAt: Date.now(), error: null }); + } catch (err) { + setSyncState((s) => ({ + status: 'error', + lastSyncedAt: s.lastSyncedAt, + error: err instanceof Error ? err.message : 'Sync failed.', + })); + } finally { + runningRef.current = false; + if (rerunRef.current) { + rerunRef.current = false; + // Held in timerRef so unmount and sign-out can cancel it; left + // loose, a sync could still fire against a signed-out session. + // A later scheduleSync supersedes it, which is correct: that one + // runs sooner and reads the same state. + if (timerRef.current) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + timerRef.current = null; + void runSync(); + }, 500); + } + } + }, []); + + const scheduleSync = useCallback((delay: number = DEBOUNCE_MS) => { + if (!userIdRef.current) return; + if (timerRef.current) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + timerRef.current = null; + void runSync(); + }, delay); + }, [runSync]); + + // Sync on becoming enabled (sign-in / entitlement load) + useEffect(() => { + if (!userId) { + setSyncState({ status: 'disabled', lastSyncedAt: null, error: null }); + if (timerRef.current) window.clearTimeout(timerRef.current); + timerRef.current = null; + return; + } + setSyncState((s) => (s.status === 'disabled' ? { ...s, status: 'idle' } : s)); + scheduleSync(200); + }, [userId, scheduleSync]); + + // Debounced sync on data changes + useEffect(() => { + if (!hasInitialized || !userId) return; + scheduleSync(); + }, [graphs, projects, hasInitialized, userId, scheduleSync]); + + // Refresh when the tab regains focus (cross-device edits) or comes online + useEffect(() => { + if (!userId) return; + const onVisible = () => { + if (document.visibilityState === 'visible' && Date.now() - lastRunRef.current > FOCUS_SYNC_MIN_INTERVAL_MS) { + scheduleSync(300); + } + }; + const onOnline = () => scheduleSync(300); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('online', onOnline); + return () => { + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('online', onOnline); + }; + }, [userId, scheduleSync]); + + // Cleanup + useEffect(() => () => { + if (timerRef.current) window.clearTimeout(timerRef.current); + timerRef.current = null; + }, []); + + const syncNow = useCallback(() => { + scheduleSync(0); + }, [scheduleSync]); + + return useMemo(() => ({ syncState, syncNow }), [syncState, syncNow]); +} diff --git a/supabase/schema.sql b/supabase/schema.sql new file mode 100644 index 0000000..a2ef0b6 --- /dev/null +++ b/supabase/schema.sql @@ -0,0 +1,664 @@ +-- ============================================================================ +-- IB EconGraph AI — Supabase schema +-- Run this in the Supabase SQL editor (or `supabase db push`) on a fresh +-- project. Safe to re-run: statements are idempotent where possible. +-- +-- Tables: +-- profiles — one row per user; billing/entitlement state (Polar) +-- projects — synced project folders +-- graphs — synced graphs (full Graph JSON in `data`) +-- graph_versions — version history snapshots (pruned client-side) +-- shares — public view-only share links (unguessable slug ids) +-- templates — user's custom component templates +-- ai_usage — hosted AI generation counters, one row per user/month +-- +-- Entitlement model: +-- The Polar webhook (server, service role) writes pro_status / pro_until. +-- A user is "Pro" while pro_until > now(). Write access to synced data is +-- gated on is_pro(); read access is owner-only but NOT pro-gated, so users +-- whose subscription lapsed can always retrieve their data. +-- ============================================================================ + +create extension if not exists pgcrypto; + +-- ---------------------------------------------------------------------------- +-- profiles +-- ---------------------------------------------------------------------------- +create table if not exists public.profiles ( + id uuid primary key references auth.users (id) on delete cascade, + email text, + display_name text, + -- Supporter recognition (opt-in name listed in the README) + supporter_name text, + show_in_supporters boolean not null default false, + -- Billing state, written only by the Polar webhook via service role + pro_status text not null default 'none', + pro_until timestamptz, + plan_interval text, + polar_customer_id text, + polar_subscription_id text, + -- `modifiedAt` of the last Polar subscription event applied to this row. The + -- webhook refuses to apply an event older than this, which is what stops a + -- delayed `subscription.active` delivered after a cancellation from handing + -- entitlement back. + polar_event_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- Added after the initial release; `create table if not exists` above skips +-- existing installs, so bring them forward explicitly. +-- +-- Deliberately left NULL for rows that already exist. NULL means "no ordering +-- baseline yet", so the first event for an existing subscriber is applied +-- unordered, and a stale one delivered in that window could extend access it +-- should not have. Seeding now() instead would reject every event stamped +-- before the migration, including a legitimate renewal that was merely slow to +-- arrive. Over-granting one event of access to an existing subscriber is +-- recoverable; cutting off someone who paid is not, and there is no value we +-- could seed that reconstructs the real last-applied time. +alter table public.profiles add column if not exists polar_event_at timestamptz; + +alter table public.profiles enable row level security; + +-- Create a profile row automatically for every new auth user. +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.profiles (id, email) + values (new.id, new.email) + on conflict (id) do nothing; + return new; +end; +$$; + +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); + +-- Trigger-only. It must stay SECURITY DEFINER (it inserts the profile row before +-- any user session exists), but it should never be callable via the REST API — +-- revoke EXECUTE so it isn't exposed as an RPC (DB linter 0028/0029). +revoke execute on function public.handle_new_user() from public, anon, authenticated; + +-- Entitlement check used by RLS policies below. SECURITY INVOKER (runs as the +-- caller): every policy calls it as is_pro(auth.uid()), so under the profiles +-- SELECT policy it can only ever read the caller's own row. Kept out of +-- SECURITY DEFINER on purpose — a definer function exposed via PostgREST is +-- what the DB linter (0028/0029) flags, and it isn't needed here. +create or replace function public.is_pro(p_user uuid) +returns boolean +language sql +stable +security invoker +set search_path = public +as $$ + select exists ( + select 1 from public.profiles + where id = p_user + and pro_until is not null + and pro_until > now() + ); +$$; + +revoke execute on function public.is_pro(uuid) from public; +grant execute on function public.is_pro(uuid) to authenticated; + +drop policy if exists "profiles: select own" on public.profiles; +create policy "profiles: select own" + on public.profiles for select + using (auth.uid() = id); + +drop policy if exists "profiles: update own" on public.profiles; +create policy "profiles: update own" + on public.profiles for update + using (auth.uid() = id) + with check (auth.uid() = id); + +-- Users may only edit their harmless profile columns; billing columns are +-- writable exclusively via the service role (column-level privileges). +revoke update on public.profiles from authenticated; +grant update (display_name, supporter_name, show_in_supporters) + on public.profiles to authenticated; + +-- ---------------------------------------------------------------------------- +-- projects +-- ---------------------------------------------------------------------------- +create table if not exists public.projects ( + id uuid primary key, + user_id uuid not null references auth.users (id) on delete cascade, + name text not null default '', + description text not null default '', + color text not null default '#3b82f6', + created_at_ms bigint not null default 0, + last_modified bigint not null default 0, + deleted boolean not null default false, + updated_at timestamptz not null default now() +); + +create index if not exists projects_user_idx on public.projects (user_id); + +alter table public.projects enable row level security; + +drop policy if exists "projects: select own" on public.projects; +create policy "projects: select own" + on public.projects for select + using (auth.uid() = user_id); + +drop policy if exists "projects: insert own (pro)" on public.projects; +create policy "projects: insert own (pro)" + on public.projects for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "projects: update own (pro)" on public.projects; +create policy "projects: update own (pro)" + on public.projects for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "projects: delete own" on public.projects; +create policy "projects: delete own" + on public.projects for delete + using (auth.uid() = user_id); + +-- ---------------------------------------------------------------------------- +-- graphs +-- ---------------------------------------------------------------------------- +create table if not exists public.graphs ( + id uuid primary key, + user_id uuid not null references auth.users (id) on delete cascade, + project_id uuid, + title text not null default '', + data jsonb not null default '{}'::jsonb, + created_at_ms bigint not null default 0, + last_modified bigint not null default 0, + deleted boolean not null default false, + updated_at timestamptz not null default now() +); + +create index if not exists graphs_user_idx on public.graphs (user_id); + +alter table public.graphs enable row level security; + +drop policy if exists "graphs: select own" on public.graphs; +create policy "graphs: select own" + on public.graphs for select + using (auth.uid() = user_id); + +drop policy if exists "graphs: insert own (pro)" on public.graphs; +create policy "graphs: insert own (pro)" + on public.graphs for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "graphs: update own (pro)" on public.graphs; +create policy "graphs: update own (pro)" + on public.graphs for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "graphs: delete own" on public.graphs; +create policy "graphs: delete own" + on public.graphs for delete + using (auth.uid() = user_id); + +-- ---------------------------------------------------------------------------- +-- graph_versions — snapshots written on every synced change, pruned to the +-- most recent N per graph by the client via prune_graph_versions(). +-- ---------------------------------------------------------------------------- +create table if not exists public.graph_versions ( + id uuid primary key default gen_random_uuid(), + graph_id uuid not null, + user_id uuid not null references auth.users (id) on delete cascade, + title text not null default '', + data jsonb not null default '{}'::jsonb, + last_modified bigint not null default 0, + created_at timestamptz not null default now() +); + +create index if not exists graph_versions_graph_idx + on public.graph_versions (graph_id, created_at desc); + +-- Soft deletes are handled by a trigger further down, but RLS also permits a +-- hard DELETE of a graph row, which would leave its history behind forever +-- (nothing else references graph_id). Cascade covers that path declaratively. +-- Orphans from before this constraint are dropped first, otherwise the +-- constraint cannot validate; their graph is already gone, so they are +-- unreachable rows. +delete from public.graph_versions v +where not exists (select 1 from public.graphs g where g.id = v.graph_id); + +do $$ +begin + -- Scoped to the table: constraint names are unique per table, not per + -- database, so an unqualified lookup can match something else entirely and + -- skip adding the foreign key this block exists to add. + if not exists ( + select 1 from pg_constraint + where conname = 'graph_versions_graph_id_fkey' + and conrelid = 'public.graph_versions'::regclass + ) then + alter table public.graph_versions + add constraint graph_versions_graph_id_fkey + foreign key (graph_id) references public.graphs (id) on delete cascade; + end if; +end; +$$; + +alter table public.graph_versions enable row level security; + +drop policy if exists "graph_versions: select own" on public.graph_versions; +create policy "graph_versions: select own" + on public.graph_versions for select + using (auth.uid() = user_id); + +drop policy if exists "graph_versions: insert own (pro)" on public.graph_versions; +create policy "graph_versions: insert own (pro)" + on public.graph_versions for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "graph_versions: delete own" on public.graph_versions; +create policy "graph_versions: delete own" + on public.graph_versions for delete + using (auth.uid() = user_id); + +-- Hard ceiling on stored versions per graph, enforced by the database itself. +-- The client asks for 30 (VERSIONS_TO_KEEP), but p_keep below is caller-supplied +-- and a tampered client could pass a huge value, or simply never call prune at +-- all, and grow this table without bound. The insert trigger further down makes +-- the cap unavoidable, so neither trick works. +create or replace function public.graph_version_cap() +returns integer +language sql +immutable +as $$ select 100 $$; + +create or replace function public.prune_graph_versions(p_graph uuid, p_keep integer default 30) +returns void +language sql +security invoker +set search_path = public +as $$ + delete from public.graph_versions + where graph_id = p_graph + and user_id = auth.uid() + and id not in ( + select id from public.graph_versions + where graph_id = p_graph and user_id = auth.uid() + order by created_at desc + -- Clamped to [1, cap]: a caller cannot request an unbounded keep count. + limit least(greatest(p_keep, 1), public.graph_version_cap()) + ); +$$; + +grant execute on function public.prune_graph_versions(uuid, integer) to authenticated; + +-- Enforce the cap on every insert, so retention never depends on the client +-- choosing to call prune_graph_versions(). SECURITY DEFINER because it must +-- delete rows during the caller's insert; it only ever touches the same +-- (graph_id, user_id) pair that was just inserted, so it cannot reach another +-- user's data. Trigger-only, so EXECUTE is revoked (DB linter 0028/0029). +create or replace function public.enforce_graph_version_cap() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + -- Serialise per graph. Two devices inserting at once would otherwise each + -- see the other's row as still-retained and both keep it, leaving more than + -- the cap. The lock is transaction-scoped and keyed on the graph, so it only + -- ever blocks a concurrent insert for that same graph. + perform pg_advisory_xact_lock(hashtextextended(new.graph_id::text, 0)); + + delete from public.graph_versions + where graph_id = new.graph_id + and user_id = new.user_id + and id not in ( + select id from public.graph_versions + where graph_id = new.graph_id and user_id = new.user_id + order by created_at desc + limit public.graph_version_cap() + ); + return null; +end; +$$; + +revoke execute on function public.enforce_graph_version_cap() from public, anon, authenticated; + +drop trigger if exists graph_versions_enforce_cap on public.graph_versions; +create trigger graph_versions_enforce_cap + after insert on public.graph_versions + for each row execute function public.enforce_graph_version_cap(); + +-- Deleting a graph must take its history with it. Deletion is a soft delete +-- (a tombstone row with deleted = true, so other devices learn about it), and +-- graph_versions has no FK to graphs, so nothing would otherwise ever remove +-- these rows: they would sit in the table until the whole account is deleted. +-- Doing it in the database means it also covers deletes from an older client. +create or replace function public.purge_versions_for_deleted_graph() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + if new.deleted and not coalesce(old.deleted, false) then + delete from public.graph_versions + where graph_id = new.id and user_id = new.user_id; + end if; + return null; +end; +$$; + +revoke execute on function public.purge_versions_for_deleted_graph() from public, anon, authenticated; + +drop trigger if exists graphs_purge_versions_on_delete on public.graphs; +create trigger graphs_purge_versions_on_delete + after insert or update of deleted on public.graphs + for each row execute function public.purge_versions_for_deleted_graph(); + +-- ---------------------------------------------------------------------------- +-- shares — view-only snapshots addressed by an unguessable slug. +-- Payloads contain diagram data only (never chat history). +-- +-- Anonymous access is served ONLY through the get_share() RPC below, which +-- returns just the payload for an exact slug match. The table itself is NOT +-- readable by anon: a blanket `using (true)` SELECT policy would let anyone +-- holding the public publishable key (which authenticates as the `anon` role) +-- bulk-enumerate every share's payload and owner user_id via PostgREST, +-- defeating the point of unguessable slugs. +-- ---------------------------------------------------------------------------- +create table if not exists public.shares ( + id text primary key, + user_id uuid not null references auth.users (id) on delete cascade, + kind text not null check (kind in ('graph', 'project')), + graph_id uuid, + project_id uuid, + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists shares_user_idx on public.shares (user_id); +create index if not exists shares_graph_idx on public.shares (graph_id); +create index if not exists shares_project_idx on public.shares (project_id); + +-- One live link per piece of content. The client looks up an existing share +-- before minting a slug, but two shares created at once would both miss and +-- each insert a row — and revoking the link shown in the UI would leave the +-- other one publicly readable. Collapse any duplicates that predate these +-- indexes (keeping the most recently updated) so they can be created. +delete from public.shares s +using public.shares t +where s.id <> t.id + and s.user_id = t.user_id + and s.kind = t.kind + and s.graph_id is not distinct from t.graph_id + and s.project_id is not distinct from t.project_id + and (s.updated_at, s.id) < (t.updated_at, t.id); + +create unique index if not exists shares_one_per_graph + on public.shares (user_id, graph_id) where kind = 'graph'; +create unique index if not exists shares_one_per_project + on public.shares (user_id, project_id) where kind = 'project'; + +alter table public.shares enable row level security; + +-- Owners can read their own share rows (needed for getShareIdFor* / refresh). +-- Public read goes through get_share() instead of a table policy. +drop policy if exists "shares: public read" on public.shares; +drop policy if exists "shares: select own" on public.shares; +create policy "shares: select own" + on public.shares for select + using (auth.uid() = user_id); + +revoke select on public.shares from anon; + +-- Anonymous slug lookup: returns only the payload, only for an exact id match. +-- No enumeration (must know the 96-bit slug), no user_id / graph_id leakage. +-- NOTE: The DB linter (0028/0029) flags this as an anon-executable SECURITY +-- DEFINER function. That is INTENTIONAL and required: anonymous visitors must +-- resolve a share link without a session, and it must bypass the shares RLS +-- (which is otherwise owner-only). It's safe because it takes an exact, +-- unguessable id and returns nothing but that row's payload. Leave as-is. +create or replace function public.get_share(p_id text) +returns jsonb +language sql +stable +security definer +set search_path = public +as $$ + select payload from public.shares where id = p_id; +$$; + +revoke execute on function public.get_share(text) from public; +grant execute on function public.get_share(text) to anon, authenticated; + +drop policy if exists "shares: insert own (pro)" on public.shares; +create policy "shares: insert own (pro)" + on public.shares for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "shares: update own (pro)" on public.shares; +create policy "shares: update own (pro)" + on public.shares for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "shares: delete own" on public.shares; +create policy "shares: delete own" + on public.shares for delete + using (auth.uid() = user_id); + +-- Deleting shared content must take its public link with it. The client already +-- prunes these in refreshShares() during sync, but that pass is best-effort and +-- its failures are swallowed, which would leave a "deleted" diagram readable by +-- anyone still holding the slug. Doing it here makes the revoke happen the +-- moment the deletion reaches the server, whichever client sent it. +-- +-- Note this covers directly shared rows only. A graph deleted out of a SHARED +-- PROJECT still needs the client to re-render that project's payload, since the +-- payload is a snapshot the database can't rebuild. +-- Covers a hard DELETE as well as the soft delete: RLS lets an owner delete the +-- row outright, and shares deliberately carry no foreign key to graphs (the +-- payload is a self-contained snapshot, so a diagram can be shared before sync +-- has pushed its row). Without the DELETE branch, hard-deleting shared content +-- leaves its public slug resolving forever. +create or replace function public.purge_shares_for_deleted_content() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + target_id uuid; + owner_id uuid; +begin + if tg_op = 'DELETE' then + target_id := old.id; + owner_id := old.user_id; + elsif new.deleted and not coalesce(old.deleted, false) then + -- On INSERT, old is NULL here (not unassigned), so coalesce is safe. + target_id := new.id; + owner_id := new.user_id; + else + return null; + end if; + + if tg_table_name = 'graphs' then + delete from public.shares + where user_id = owner_id and kind = 'graph' and graph_id = target_id; + else + delete from public.shares + where user_id = owner_id and kind = 'project' and project_id = target_id; + end if; + return null; +end; +$$; + +revoke execute on function public.purge_shares_for_deleted_content() from public, anon, authenticated; + +drop trigger if exists graphs_purge_shares_on_delete on public.graphs; +create trigger graphs_purge_shares_on_delete + after insert or update of deleted or delete on public.graphs + for each row execute function public.purge_shares_for_deleted_content(); + +drop trigger if exists projects_purge_shares_on_delete on public.projects; +create trigger projects_purge_shares_on_delete + after insert or update of deleted or delete on public.projects + for each row execute function public.purge_shares_for_deleted_content(); + +-- ---------------------------------------------------------------------------- +-- Last-write-wins, enforced by the database +-- ---------------------------------------------------------------------------- +-- The client resolves conflicts by reading every remote row, comparing +-- last_modified, and upserting whatever it decided is newer. That comparison is +-- only valid at the instant of the read: two devices (or two tabs, or a +-- debounced sync overlapping a focus sync) both read the same baseline, and the +-- slower push lands last, overwriting a newer diagram with older content. The +-- read and the write are not atomic and no amount of client-side care makes +-- them so. +-- +-- Doing the comparison inside the write itself closes the window. Returning +-- null from a BEFORE UPDATE trigger skips that row's update and leaves the +-- stored row intact, so a stale push is dropped rather than rejected: the +-- pushing client is not carrying newer data, it just no longer has anything to +-- contribute, and failing its whole batch would be worse. +-- +-- Equal timestamps still write, which keeps re-pushing the same row idempotent +-- and lets a tombstone (whose timestamp is the delete's, and is only ever +-- queued when it is >= the remote row's) win against the row it deletes. +create or replace function public.reject_stale_write() +returns trigger +language plpgsql +as $$ +begin + if new.last_modified < old.last_modified then + return null; + end if; + return new; +end; +$$; + +drop trigger if exists graphs_reject_stale_write on public.graphs; +create trigger graphs_reject_stale_write + before update on public.graphs + for each row execute function public.reject_stale_write(); + +drop trigger if exists projects_reject_stale_write on public.projects; +create trigger projects_reject_stale_write + before update on public.projects + for each row execute function public.reject_stale_write(); + +-- ---------------------------------------------------------------------------- +-- templates — user's custom component templates (synced) +-- ---------------------------------------------------------------------------- +create table if not exists public.templates ( + id uuid primary key, + user_id uuid not null references auth.users (id) on delete cascade, + name text not null default '', + description text not null default '', + category text not null default 'custom', + data jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + last_modified bigint not null default 0 +); + +create index if not exists templates_user_idx on public.templates (user_id); + +alter table public.templates enable row level security; + +drop policy if exists "templates: select own" on public.templates; +create policy "templates: select own" + on public.templates for select + using (auth.uid() = user_id); + +drop policy if exists "templates: insert own (pro)" on public.templates; +create policy "templates: insert own (pro)" + on public.templates for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "templates: update own (pro)" on public.templates; +create policy "templates: update own (pro)" + on public.templates for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "templates: delete own" on public.templates; +create policy "templates: delete own" + on public.templates for delete + using (auth.uid() = user_id); + +-- ---------------------------------------------------------------------------- +-- ai_usage — hosted AI metering. Written only by the server (service role) +-- through the atomic functions below. Users can read their own row. +-- ---------------------------------------------------------------------------- +create table if not exists public.ai_usage ( + user_id uuid not null references auth.users (id) on delete cascade, + month text not null, -- 'YYYY-MM' (UTC) + count integer not null default 0, + updated_at timestamptz not null default now(), + primary key (user_id, month) +); + +alter table public.ai_usage enable row level security; + +drop policy if exists "ai_usage: select own" on public.ai_usage; +create policy "ai_usage: select own" + on public.ai_usage for select + using (auth.uid() = user_id); + +-- Atomically increment usage if under the limit. Returns the new count, or +-- -1 when the limit has been reached (row unchanged). +create or replace function public.increment_ai_usage(p_user uuid, p_month text, p_limit integer) +returns integer +language plpgsql +security definer +set search_path = public +as $$ +declare + new_count integer; +begin + -- A non-positive limit means "no generations allowed". Without this the very + -- first call each month would still succeed, because the limit is only + -- checked on the conflict path below. + if p_limit <= 0 then + return -1; + end if; + + insert into public.ai_usage (user_id, month, count) + values (p_user, p_month, 1) + on conflict (user_id, month) do update + set count = ai_usage.count + 1, + updated_at = now() + where ai_usage.count < p_limit + returning count into new_count; + + if new_count is null then + return -1; + end if; + return new_count; +end; +$$; + +-- Refund one generation (used when the upstream AI call fails after metering). +create or replace function public.refund_ai_usage(p_user uuid, p_month text) +returns void +language sql +security definer +set search_path = public +as $$ + update public.ai_usage + set count = greatest(count - 1, 0), + updated_at = now() + where user_id = p_user and month = p_month; +$$; + +-- These are only ever called with the service role key. +revoke execute on function public.increment_ai_usage(uuid, text, integer) from public, anon, authenticated; +revoke execute on function public.refund_ai_usage(uuid, text) from public, anon, authenticated; diff --git a/types.ts b/types.ts index a47ec77..3b0fb55 100644 --- a/types.ts +++ b/types.ts @@ -84,6 +84,13 @@ export interface Conversation { export interface Graph { id: string; title: string; + /** + * Set once the user names the graph themselves (rename dialog, or editing the + * title on the canvas). While it is unset the AI is free to retitle the graph + * on each generation. Absent on graphs saved before this flag existed, which + * fall back to a title-based heuristic. + */ + titleSetByUser?: boolean; caption: string; // User-editable figure caption projectId?: string; // Optional: which project this graph belongs to messages: Message[]; diff --git a/vercel.json b/vercel.json index 1323cda..2892ca3 100644 --- a/vercel.json +++ b/vercel.json @@ -1,7 +1,14 @@ { + "cleanUrls": true, + "trailingSlash": false, + "functions": { + "api/generate.ts": { + "maxDuration": 60 + } + }, "rewrites": [ { - "source": "/(.*)", + "source": "/((?!api/).*)", "destination": "/index.html" } ] diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 0000000..85d897e --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_SUPABASE_URL?: string; + readonly VITE_SUPABASE_PUBLISHABLE_KEY?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index b35f9b9..a198662 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,23 +1,157 @@ import path from 'path'; -import { defineConfig, loadEnv } from 'vite'; +import fs from 'node:fs'; +import { defineConfig, loadEnv, type Plugin, type ViteDevServer } from 'vite'; import react from '@vitejs/plugin-react'; -export default defineConfig(({ mode }) => { +/** + * Dev-only shim: serve the Vercel serverless functions in `api/` directly from + * the Vite dev server, so `npm run dev` exercises the real handlers (checkout, + * usage, portal, webhooks…) without needing `vercel dev`. It maps `/api/` + * to `api/.ts`, runs the module's default export, and adapts Node's + * req/res to the small slice of the Vercel Node API the handlers use + * (`req.query`, `req.body`, `res.status().json()`…). Production still runs on + * the real Vercel runtime — this only exists for `command === 'serve'`. + */ +function devApiPlugin(root: string): Plugin { + return { + name: 'dev-api-functions', + apply: 'serve', + configureServer(server: ViteDevServer) { + // Registering here (not in a returned callback) runs the middleware + // before Vite's SPA history fallback, so /api isn't rewritten to index.html. + server.middlewares.use(async (req: any, res: any, next: () => void) => { + if (!req.url || !req.url.startsWith('/api/')) return next(); + + const parsed = new URL(req.url, 'http://localhost'); + const rel = parsed.pathname.replace(/^\/api\//, '').replace(/\/+$/, ''); + // `/api/../../secret` would otherwise escape the api directory + // through path.join. Only plain nested route segments are valid. + if (!/^[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*$/.test(rel)) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: `No API route for ${parsed.pathname}` })); + return; + } + const variants = [ + { abs: path.join(root, 'api', `${rel}.ts`), id: `/api/${rel}.ts` }, + { abs: path.join(root, 'api', rel, 'index.ts'), id: `/api/${rel}/index.ts` }, + ]; + const match = variants.find((v) => fs.existsSync(v.abs)); + if (!match) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: `No API route for ${parsed.pathname}` })); + return; + } + + // Vercel-style request extras. + req.query = Object.fromEntries(parsed.searchParams); + // Webhook handlers read the raw body themselves (bodyParser is + // disabled), so leave their stream untouched. Everything else + // gets a parsed JSON body. + if (!parsed.pathname.startsWith('/api/webhooks/')) { + req.body = await readJsonBody(req); + } + + // Vercel-style response helpers. + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (obj: unknown) => { + if (!res.getHeader('Content-Type')) res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(obj)); + return res; + }; + res.send = (data: unknown) => { + res.end(typeof data === 'string' || Buffer.isBuffer(data) ? data : JSON.stringify(data)); + return res; + }; + res.redirect = (url: string) => { + res.statusCode = 302; + res.setHeader('Location', url); + res.end(); + return res; + }; + + try { + const mod = await server.ssrLoadModule(match.id); + const handler = mod.default as ((req: unknown, res: unknown) => unknown) | undefined; + if (typeof handler !== 'function') { + throw new Error(`API route ${rel} has no default export handler`); + } + await handler(req, res); + } catch (err) { + server.config.logger.error(`[dev-api] ${rel} failed:\n${(err as Error).stack || err}`); + if (!res.writableEnded) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Dev API handler error (see terminal).' })); + } + } + }); + }, + }; +} + +/** Cap the dev-server body so one oversized request can't exhaust the process. */ +const MAX_DEV_BODY_BYTES = 2 * 1024 * 1024; + +function readJsonBody(req: any): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + let size = 0; + req.on('data', (c: Buffer) => { + size += c.length; + if (size > MAX_DEV_BODY_BYTES) { + req.destroy(); + resolve(undefined); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (chunks.length === 0) return resolve(undefined); + const raw = Buffer.concat(chunks).toString('utf8'); + const ct = String(req.headers['content-type'] || ''); + if (ct.includes('application/json')) { + try { resolve(JSON.parse(raw)); } catch { resolve(undefined); } + } else { + resolve(raw); + } + }); + req.on('error', () => resolve(undefined)); + }); +} + +export default defineConfig(({ mode, command }) => { const env = loadEnv(mode, '.', ''); + if (command === 'serve') { + // Expose server-side vars (SUPABASE_SECRET_KEY, POLAR_*, GEMINI_API_KEY…) + // to the dev API handlers, which run in this Node process via ssrLoadModule. + // Does not affect the client bundle — only VITE_-prefixed vars reach that. + for (const [k, v] of Object.entries(env)) { + if (process.env[k] === undefined) process.env[k] = v; + } + } return { - server: { - port: 4000, - host: '0.0.0.0', - }, - plugins: [react()], - define: { - 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), - 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) - }, - resolve: { - alias: { - '@': path.resolve(__dirname, '.'), + server: { + port: 4000, + host: '0.0.0.0', + // Allow access through public dev tunnels (used for testing the + // Polar webhook/redirect against a real HTTPS origin). Vite otherwise + // rejects non-localhost Host headers with "This host is not allowed". + // Keep in step with DEV_TUNNEL_SUFFIXES in api/_lib/polar.ts: a host + // that one accepts and the other rejects fails only after Polar + // redirects back, which is the least helpful moment to find out. + allowedHosts: ['.devtunnels.ms', '.ngrok-free.app', '.ngrok.app', '.trycloudflare.com'], + }, + plugins: [react(), devApiPlugin(__dirname)], + // No `define` for GEMINI_API_KEY on purpose: it would inline the server's + // key into the client bundle for anyone to read. The browser talks to + // /api/generate, which holds the key server-side; users on their own key + // supply it at runtime through Settings. + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + } } - } }; });