From c1a097debfa76118bb5865217e13da212b5de450 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 13:02:06 +0000 Subject: [PATCH 01/10] Initial plan From 5cbc0a7052a2994b2aa41708e1141848c6fe2ebf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 13:31:09 +0000 Subject: [PATCH 02/10] feat: build PromptOS monorepo foundation - Root: pnpm workspaces, Turborepo, tsconfig.base, .gitignore, .env.example - packages/contracts: billing, access, usage, ai, plugin, sync, web3 contracts (Zod-validated) - packages/services: Stripe, Firebase, AI router (OpenAI/Claude/Gemini), plugin engine - packages/middleware: JWT auth, billing tier, RBAC, usage/rate-limit middleware - packages/ui: GlassCard, NeonButton, AICommandBar, PromptCard, BillingDashboard, PluginTile (NEON GLASS OS theme) - packages/config: Zod-validated env config - apps/web: Next.js 14 App Router, API routes (billing, ai, plugins, usage, stripe webhook), dashboard + optimizer pages - apps/desktop: Electron 28 main process + preload, electron-builder config - apps/mobile: React Native Expo 50, bottom tab nav, optimizer + dashboard screens - Docker: Dockerfile.web, docker-compose.yml - CI/CD: GitHub Actions ci.yml (lint + type-check + build), deploy-web.yml (Vercel) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: SMSDAO <144380926+SMSDAO@users.noreply.github.com> --- .env.example | 41 +++ .github/workflows/ci.yml | 94 +++++++ .github/workflows/deploy-web.yml | 47 ++++ .gitignore | 45 ++++ Dockerfile.web | 49 ++++ apps/desktop/package.json | 54 ++++ apps/desktop/src/main.ts | 131 ++++++++++ apps/desktop/src/preload.ts | 15 ++ apps/desktop/tsconfig.json | 12 + apps/mobile/app.json | 33 +++ apps/mobile/package.json | 38 +++ apps/mobile/src/navigation/TabNavigator.tsx | 57 +++++ apps/mobile/src/screens/DashboardScreen.tsx | 81 ++++++ apps/mobile/src/screens/OptimizerScreen.tsx | 182 ++++++++++++++ apps/mobile/src/screens/PluginsScreen.tsx | 36 +++ apps/mobile/src/screens/SettingsScreen.tsx | 82 ++++++ apps/mobile/tsconfig.json | 11 + apps/web/.env.example | 25 ++ apps/web/next.config.js | 21 ++ apps/web/package.json | 38 +++ apps/web/postcss.config.js | 6 + apps/web/src/app/api/ai/complete/route.ts | 19 ++ apps/web/src/app/api/ai/optimize/route.ts | 33 +++ apps/web/src/app/api/billing/cancel/route.ts | 20 ++ .../web/src/app/api/billing/checkout/route.ts | 36 +++ apps/web/src/app/api/billing/portal/route.ts | 25 ++ apps/web/src/app/api/plugins/install/route.ts | 35 +++ .../src/app/api/plugins/uninstall/route.ts | 22 ++ apps/web/src/app/api/usage/summary/route.ts | 47 ++++ apps/web/src/app/api/webhooks/stripe/route.ts | 63 +++++ apps/web/src/app/dashboard/page.tsx | 78 ++++++ apps/web/src/app/globals.css | 51 ++++ apps/web/src/app/layout.tsx | 33 +++ apps/web/src/app/optimizer/page.tsx | 113 +++++++++ apps/web/src/app/page.tsx | 38 +++ apps/web/tailwind.config.ts | 12 + apps/web/tsconfig.json | 18 ++ docker-compose.yml | 32 +++ package.json | 25 ++ packages/config/package.json | 28 +++ packages/config/src/index.ts | 75 ++++++ packages/config/tsconfig.json | 11 + packages/contracts/package.json | 28 +++ packages/contracts/src/access.contract.ts | 119 +++++++++ packages/contracts/src/ai.contract.ts | 103 ++++++++ packages/contracts/src/billing.contract.ts | 143 +++++++++++ packages/contracts/src/index.ts | 7 + packages/contracts/src/plugin.contract.ts | 104 ++++++++ packages/contracts/src/sync.contract.ts | 83 ++++++ packages/contracts/src/usage.contract.ts | 88 +++++++ packages/contracts/src/web3.contract.ts | 79 ++++++ packages/contracts/tsconfig.json | 11 + packages/middleware/package.json | 39 +++ packages/middleware/src/auth.middleware.ts | 71 ++++++ packages/middleware/src/billing.middleware.ts | 56 +++++ packages/middleware/src/index.ts | 4 + packages/middleware/src/rbac.middleware.ts | 75 ++++++ packages/middleware/src/usage.middleware.ts | 113 +++++++++ packages/middleware/tsconfig.json | 11 + packages/services/package.json | 39 +++ packages/services/src/ai.router.service.ts | 237 ++++++++++++++++++ packages/services/src/firebase.service.ts | 135 ++++++++++ packages/services/src/index.ts | 4 + packages/services/src/plugin.engine.ts | 171 +++++++++++++ packages/services/src/stripe.service.ts | 158 ++++++++++++ packages/services/tsconfig.json | 11 + packages/ui/package.json | 40 +++ packages/ui/src/components/AICommandBar.tsx | 141 +++++++++++ .../ui/src/components/BillingDashboard.tsx | 152 +++++++++++ packages/ui/src/components/GlassCard.tsx | 61 +++++ packages/ui/src/components/NeonButton.tsx | 91 +++++++ packages/ui/src/components/PluginTile.tsx | 131 ++++++++++ packages/ui/src/components/PromptCard.tsx | 117 +++++++++ packages/ui/src/index.ts | 6 + packages/ui/tailwind.config.ts | 90 +++++++ packages/ui/tsconfig.json | 13 + pnpm-workspace.yaml | 3 + tsconfig.base.json | 26 ++ turbo.json | 28 +++ 79 files changed, 4700 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy-web.yml create mode 100644 .gitignore create mode 100644 Dockerfile.web create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/src/main.ts create mode 100644 apps/desktop/src/preload.ts create mode 100644 apps/desktop/tsconfig.json create mode 100644 apps/mobile/app.json create mode 100644 apps/mobile/package.json create mode 100644 apps/mobile/src/navigation/TabNavigator.tsx create mode 100644 apps/mobile/src/screens/DashboardScreen.tsx create mode 100644 apps/mobile/src/screens/OptimizerScreen.tsx create mode 100644 apps/mobile/src/screens/PluginsScreen.tsx create mode 100644 apps/mobile/src/screens/SettingsScreen.tsx create mode 100644 apps/mobile/tsconfig.json create mode 100644 apps/web/.env.example create mode 100644 apps/web/next.config.js create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/src/app/api/ai/complete/route.ts create mode 100644 apps/web/src/app/api/ai/optimize/route.ts create mode 100644 apps/web/src/app/api/billing/cancel/route.ts create mode 100644 apps/web/src/app/api/billing/checkout/route.ts create mode 100644 apps/web/src/app/api/billing/portal/route.ts create mode 100644 apps/web/src/app/api/plugins/install/route.ts create mode 100644 apps/web/src/app/api/plugins/uninstall/route.ts create mode 100644 apps/web/src/app/api/usage/summary/route.ts create mode 100644 apps/web/src/app/api/webhooks/stripe/route.ts create mode 100644 apps/web/src/app/dashboard/page.tsx create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/optimizer/page.tsx create mode 100644 apps/web/src/app/page.tsx create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 packages/config/package.json create mode 100644 packages/config/src/index.ts create mode 100644 packages/config/tsconfig.json create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/access.contract.ts create mode 100644 packages/contracts/src/ai.contract.ts create mode 100644 packages/contracts/src/billing.contract.ts create mode 100644 packages/contracts/src/index.ts create mode 100644 packages/contracts/src/plugin.contract.ts create mode 100644 packages/contracts/src/sync.contract.ts create mode 100644 packages/contracts/src/usage.contract.ts create mode 100644 packages/contracts/src/web3.contract.ts create mode 100644 packages/contracts/tsconfig.json create mode 100644 packages/middleware/package.json create mode 100644 packages/middleware/src/auth.middleware.ts create mode 100644 packages/middleware/src/billing.middleware.ts create mode 100644 packages/middleware/src/index.ts create mode 100644 packages/middleware/src/rbac.middleware.ts create mode 100644 packages/middleware/src/usage.middleware.ts create mode 100644 packages/middleware/tsconfig.json create mode 100644 packages/services/package.json create mode 100644 packages/services/src/ai.router.service.ts create mode 100644 packages/services/src/firebase.service.ts create mode 100644 packages/services/src/index.ts create mode 100644 packages/services/src/plugin.engine.ts create mode 100644 packages/services/src/stripe.service.ts create mode 100644 packages/services/tsconfig.json create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/components/AICommandBar.tsx create mode 100644 packages/ui/src/components/BillingDashboard.tsx create mode 100644 packages/ui/src/components/GlassCard.tsx create mode 100644 packages/ui/src/components/NeonButton.tsx create mode 100644 packages/ui/src/components/PluginTile.tsx create mode 100644 packages/ui/src/components/PromptCard.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/tailwind.config.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 turbo.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45dd5dc --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# ─── App ────────────────────────────────────────────────────────────────────── +NODE_ENV=development +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# ─── Authentication ─────────────────────────────────────────────────────────── +JWT_SECRET=your-256-bit-jwt-secret-here +JWT_EXPIRES_IN=7d +ENCRYPTION_KEY=your-32-byte-aes-256-encryption-key-here + +# ─── Stripe ─────────────────────────────────────────────────────────────────── +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key +STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret +STRIPE_PRICE_PRO_MONTHLY=price_pro_monthly_id +STRIPE_PRICE_PRO_YEARLY=price_pro_yearly_id +STRIPE_PRICE_ENTERPRISE_MONTHLY=price_enterprise_monthly_id +STRIPE_PRICE_ENTERPRISE_YEARLY=price_enterprise_yearly_id + +# ─── AI Providers ───────────────────────────────────────────────────────────── +OPENAI_API_KEY=sk-your-openai-api-key +ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key +GOOGLE_AI_API_KEY=your-google-ai-api-key + +# ─── Firebase ───────────────────────────────────────────────────────────────── +FIREBASE_PROJECT_ID=your-firebase-project-id +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nyour-key\n-----END PRIVATE KEY-----" +FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com +NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +NEXT_PUBLIC_FIREBASE_DATABASE_URL=https://your-project-default-rtdb.firebaseio.com +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project.appspot.com +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789 +NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abcdef + +# ─── WalletConnect / Web3 ───────────────────────────────────────────────────── +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your-walletconnect-project-id +NFT_CONTRACT_ADDRESS=0xYourNFTContractAddress + +# ─── Rate Limiting ──────────────────────────────────────────────────────────── +UPSTASH_REDIS_REST_URL=https://your-upstash-url.upstash.io +UPSTASH_REDIS_REST_TOKEN=your-upstash-token diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c3754b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-type-check: + name: Lint & Type Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm turbo run type-check + + - name: Lint + run: pnpm turbo run lint + + build: + name: Build + runs-on: ubuntu-latest + needs: lint-type-check + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm- + + - name: Cache Turbo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm turbo run build --filter=!@promptos/web --filter=!@promptos/desktop --filter=!@promptos/mobile + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..a12623c --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,47 @@ +name: Deploy Web + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy-web-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy to Vercel + runs-on: ubuntu-latest + environment: + name: production + url: ${{ steps.deploy.outputs.url }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + + - name: Install Vercel CLI + run: npm install -g vercel@latest + + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + + - name: Build Project Artifacts + run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} + + - name: Deploy to Vercel + id: deploy + run: | + URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}) + echo "url=$URL" >> $GITHUB_OUTPUT + echo "Deployed to: $URL" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ee813a --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules +.pnpm-store + +# Build outputs +.next +dist +build +out +.expo + +# Environment +.env +.env.local +.env.*.local +!.env.example + +# Turbo cache +.turbo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Testing +coverage + +# IDE +.vscode +.idea +*.swp +*.swo + +# Electron +app/dist +app/build +release + +# TypeScript +*.tsbuildinfo diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..424a277 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,49 @@ +FROM node:20-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +RUN npm install -g pnpm@8 +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/contracts/package.json ./packages/contracts/ +COPY packages/services/package.json ./packages/services/ +COPY packages/middleware/package.json ./packages/middleware/ +COPY packages/ui/package.json ./packages/ui/ +COPY packages/config/package.json ./packages/config/ +RUN pnpm install --frozen-lockfile + +FROM base AS builder +WORKDIR /app +RUN npm install -g pnpm@8 +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY . . + +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_FIREBASE_API_KEY +ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN +ARG NEXT_PUBLIC_FIREBASE_DATABASE_URL +ARG NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET +ARG NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID +ARG NEXT_PUBLIC_FIREBASE_APP_ID + +RUN pnpm turbo run build --filter=@promptos/web + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/apps/web/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT 3000 +ENV HOSTNAME "0.0.0.0" + +CMD ["node", "apps/web/server.js"] diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..8d40fbb --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,54 @@ +{ + "name": "@promptos/desktop", + "version": "0.1.0", + "private": true, + "description": "PromptOS Desktop Application", + "main": "src/main.js", + "scripts": { + "dev": "concurrently \"npm run dev:next\" \"wait-on http://localhost:3000 && electron .\"", + "dev:next": "cd ../web && next dev", + "build": "electron-builder --publish=never", + "build:mac": "electron-builder --mac --publish=never", + "build:win": "electron-builder --win --publish=never", + "build:linux": "electron-builder --linux --publish=never", + "type-check": "tsc --noEmit", + "lint": "eslint src/" + }, + "dependencies": { + "electron-updater": "^6.3.4" + }, + "devDependencies": { + "concurrently": "^8.2.2", + "electron": "^28.3.3", + "electron-builder": "^24.13.3", + "typescript": "^5.4.5", + "wait-on": "^7.2.0" + }, + "build": { + "appId": "io.promptos.desktop", + "productName": "PromptOS", + "directories": { + "buildResources": "assets", + "output": "dist" + }, + "files": ["src/**/*", "assets/**/*"], + "mac": { + "category": "public.app-category.productivity", + "icon": "assets/icon.icns", + "target": ["dmg", "zip"] + }, + "win": { + "target": ["nsis", "zip"], + "icon": "assets/icon.ico" + }, + "linux": { + "target": ["AppImage", "deb"], + "icon": "assets/icon.png", + "category": "Utility" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + } + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts new file mode 100644 index 0000000..7f7e97d --- /dev/null +++ b/apps/desktop/src/main.ts @@ -0,0 +1,131 @@ +import { app, BrowserWindow, shell, ipcMain, nativeTheme, Menu } from "electron"; +import path from "path"; +import { autoUpdater } from "electron-updater"; + +const isDev = process.env["NODE_ENV"] !== "production"; +const WEB_URL = isDev ? "http://localhost:3000" : "https://app.promptos.io"; + +let mainWindow: BrowserWindow | null = null; + +function createWindow(): void { + nativeTheme.themeSource = "dark"; + + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 900, + minHeight: 600, + title: "PromptOS", + backgroundColor: "#05060A", + titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", + trafficLightPosition: { x: 16, y: 16 }, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + preload: path.join(__dirname, "preload.js"), + }, + show: false, + }); + + mainWindow.loadURL(WEB_URL).catch((err) => { + console.error("Failed to load URL:", err); + }); + + mainWindow.once("ready-to-show", () => { + mainWindow?.show(); + if (isDev) { + mainWindow?.webContents.openDevTools({ mode: "detach" }); + } + }); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: "deny" }; + }); + + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +function buildMenu(): void { + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: "PromptOS", + submenu: [ + { role: "about" }, + { type: "separator" }, + { + label: "Check for Updates", + click: () => autoUpdater.checkForUpdatesAndNotify(), + }, + { type: "separator" }, + { role: "quit" }, + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: [{ role: "minimize" }, { role: "zoom" }, { role: "close" }], + }, + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +app.on("ready", () => { + buildMenu(); + createWindow(); + + if (!isDev) { + autoUpdater.checkForUpdatesAndNotify().catch(console.error); + } +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); + +app.on("activate", () => { + if (mainWindow === null) createWindow(); +}); + +ipcMain.handle("app:version", () => app.getVersion()); +ipcMain.handle("app:platform", () => process.platform); + +autoUpdater.on("update-available", () => { + mainWindow?.webContents.send("update:available"); +}); + +autoUpdater.on("update-downloaded", () => { + mainWindow?.webContents.send("update:downloaded"); +}); + +ipcMain.handle("update:install", () => { + autoUpdater.quitAndInstall(); +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts new file mode 100644 index 0000000..af3dc0f --- /dev/null +++ b/apps/desktop/src/preload.ts @@ -0,0 +1,15 @@ +import { contextBridge, ipcRenderer } from "electron"; + +contextBridge.exposeInMainWorld("electronAPI", { + getVersion: () => ipcRenderer.invoke("app:version") as Promise, + getPlatform: () => ipcRenderer.invoke("app:platform") as Promise, + installUpdate: () => ipcRenderer.invoke("update:install") as Promise, + onUpdateAvailable: (callback: () => void) => { + ipcRenderer.on("update:available", callback); + return () => ipcRenderer.removeListener("update:available", callback); + }, + onUpdateDownloaded: (callback: () => void) => { + ipcRenderer.on("update:downloaded", callback); + return () => ipcRenderer.removeListener("update:downloaded", callback); + }, +}); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..41cb961 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "CommonJS", + "moduleResolution": "node", + "lib": ["ES2022"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000..6b12ee5 --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,33 @@ +{ + "expo": { + "name": "PromptOS", + "slug": "promptos", + "version": "0.1.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "dark", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#05060A" + }, + "assetBundlePatterns": ["**/*"], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "io.promptos.mobile" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#05060A" + }, + "package": "io.promptos.mobile" + }, + "plugins": ["expo-router"], + "scheme": "promptos", + "extra": { + "router": { "origin": false }, + "eas": { "projectId": "your-eas-project-id" } + } + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..7077588 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,38 @@ +{ + "name": "@promptos/mobile", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "dev": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web", + "build:android": "eas build --platform android", + "build:ios": "eas build --platform ios", + "lint": "eslint src/", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@promptos/contracts": "workspace:*", + "expo": "~50.0.20", + "expo-router": "~3.5.23", + "expo-status-bar": "~1.12.1", + "expo-secure-store": "~13.0.2", + "expo-font": "~12.0.9", + "react": "18.2.0", + "react-native": "0.73.6", + "@react-navigation/native": "^6.1.17", + "@react-navigation/bottom-tabs": "^6.5.20", + "react-native-safe-area-context": "4.9.0", + "react-native-screens": "~3.29.0", + "react-native-reanimated": "~3.6.2", + "react-native-gesture-handler": "~2.16.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@babel/core": "^7.24.0", + "@types/react": "~18.2.79", + "typescript": "^5.4.5" + } +} diff --git a/apps/mobile/src/navigation/TabNavigator.tsx b/apps/mobile/src/navigation/TabNavigator.tsx new file mode 100644 index 0000000..f2de8e6 --- /dev/null +++ b/apps/mobile/src/navigation/TabNavigator.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { StyleSheet, Text, View } from "react-native"; +import { DashboardScreen } from "../screens/DashboardScreen.js"; +import { OptimizerScreen } from "../screens/OptimizerScreen.js"; +import { PluginsScreen } from "../screens/PluginsScreen.js"; +import { SettingsScreen } from "../screens/SettingsScreen.js"; + +const Tab = createBottomTabNavigator(); + +const NEON_CYAN = "#00F5FF"; +const DEEP_SPACE = "#05060A"; +const GLASS_BORDER = "rgba(0,245,255,0.15)"; + +function TabIcon({ name, focused }: { name: string; focused: boolean }) { + const icons: Record = { + Dashboard: "⬡", + Optimizer: "✦", + Plugins: "◈", + Settings: "⚙", + }; + return ( + + {icons[name] ?? "○"} + + ); +} + +export function TabNavigator() { + return ( + ({ + headerShown: false, + tabBarIcon: ({ focused }) => , + tabBarActiveTintColor: NEON_CYAN, + tabBarInactiveTintColor: "rgba(255,255,255,0.3)", + tabBarStyle: { + backgroundColor: "rgba(5,6,10,0.95)", + borderTopColor: GLASS_BORDER, + borderTopWidth: 1, + height: 64, + paddingBottom: 8, + paddingTop: 8, + }, + tabBarLabelStyle: { + fontSize: 10, + fontFamily: "monospace", + }, + })} + > + + + + + + ); +} diff --git a/apps/mobile/src/screens/DashboardScreen.tsx b/apps/mobile/src/screens/DashboardScreen.tsx new file mode 100644 index 0000000..c10e195 --- /dev/null +++ b/apps/mobile/src/screens/DashboardScreen.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { View, Text, ScrollView, StyleSheet } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +const stats = [ + { label: "Prompts", value: "0", color: "#00F5FF" }, + { label: "Tokens", value: "0", color: "#A855F7" }, + { label: "Saved", value: "$0.00", color: "#00FF88" }, + { label: "Plan", value: "FREE", color: "#FF0090" }, +]; + +export function DashboardScreen() { + return ( + + + + PromptOS + Command Center + + + + {stats.map((stat) => ( + + {stat.label} + {stat.value} + + ))} + + + + Recent Activity + No activity yet. Create your first prompt to get started. + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#05060A" }, + scroll: { flex: 1 }, + content: { padding: 20, paddingBottom: 40 }, + header: { marginBottom: 24 }, + logo: { + fontSize: 28, + fontWeight: "900", + color: "#00F5FF", + fontFamily: "monospace", + letterSpacing: 2, + }, + subtitle: { fontSize: 12, color: "rgba(255,255,255,0.4)", marginTop: 2 }, + grid: { flexDirection: "row", flexWrap: "wrap", gap: 10, marginBottom: 16 }, + statCard: { + flex: 1, + minWidth: "45%", + borderRadius: 10, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.08)", + backgroundColor: "rgba(255,255,255,0.04)", + padding: 14, + }, + statLabel: { fontSize: 11, color: "rgba(255,255,255,0.4)", marginBottom: 6 }, + statValue: { fontSize: 22, fontWeight: "bold", fontFamily: "monospace" }, + activityCard: { + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(0,245,255,0.2)", + backgroundColor: "rgba(255,255,255,0.04)", + padding: 16, + minHeight: 120, + justifyContent: "center", + }, + sectionTitle: { + fontSize: 13, + fontWeight: "600", + color: "#ffffff", + marginBottom: 12, + fontFamily: "monospace", + }, + empty: { fontSize: 12, color: "rgba(255,255,255,0.3)", textAlign: "center" }, +}); diff --git a/apps/mobile/src/screens/OptimizerScreen.tsx b/apps/mobile/src/screens/OptimizerScreen.tsx new file mode 100644 index 0000000..38b2d9a --- /dev/null +++ b/apps/mobile/src/screens/OptimizerScreen.tsx @@ -0,0 +1,182 @@ +import React, { useState } from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + ScrollView, + ActivityIndicator, + StyleSheet, + Alert, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +interface OptimizationResult { + optimizedPrompt: string; + improvements: string[]; + scoreImprovement: number; + tokensReduced: number; +} + +const API_URL = process.env["EXPO_PUBLIC_API_URL"] ?? "http://localhost:3000"; + +export function OptimizerScreen() { + const [prompt, setPrompt] = useState(""); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleOptimize(): Promise { + if (!prompt.trim()) return; + setLoading(true); + try { + const res = await fetch(`${API_URL}/api/ai/optimize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: prompt.trim() }), + }); + if (!res.ok) throw new Error("Optimization failed"); + const data = (await res.json()) as OptimizationResult; + setResult(data); + } catch (err) { + Alert.alert("Error", err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + } + + return ( + + + Prompt Optimizer + AI-powered enhancement + + + Your Prompt + + + void handleOptimize()} + disabled={!prompt.trim() || loading} + activeOpacity={0.8} + > + {loading ? ( + + ) : ( + ✦ Optimize Prompt + )} + + + + {result && ( + + + Optimized + + +{result.scoreImprovement}% + -{result.tokensReduced} tkn + + + {result.optimizedPrompt} + + {result.improvements.length > 0 && ( + + Improvements + {result.improvements.map((imp, i) => ( + + ▸ {imp} + + ))} + + )} + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#05060A" }, + scroll: { flex: 1 }, + content: { padding: 20, paddingBottom: 40 }, + title: { + fontSize: 22, + fontWeight: "bold", + color: "#ffffff", + fontFamily: "monospace", + marginBottom: 2, + }, + subtitle: { fontSize: 12, color: "rgba(255,255,255,0.4)", marginBottom: 20 }, + inputCard: { + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(0,245,255,0.2)", + backgroundColor: "rgba(255,255,255,0.04)", + padding: 16, + marginBottom: 16, + }, + label: { fontSize: 12, color: "rgba(255,255,255,0.5)", marginBottom: 8 }, + input: { + color: "#ffffff", + fontSize: 13, + fontFamily: "monospace", + minHeight: 120, + backgroundColor: "rgba(255,255,255,0.03)", + borderRadius: 8, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.06)", + padding: 10, + marginBottom: 12, + }, + button: { + borderRadius: 8, + borderWidth: 1, + borderColor: "#00F5FF", + backgroundColor: "rgba(0,245,255,0.1)", + padding: 12, + alignItems: "center", + }, + buttonDisabled: { opacity: 0.4 }, + buttonText: { color: "#00F5FF", fontSize: 13, fontWeight: "600" }, + resultCard: { + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(0,255,136,0.3)", + backgroundColor: "rgba(0,255,136,0.04)", + padding: 16, + }, + resultHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 10, + }, + resultTitle: { fontSize: 13, fontWeight: "600", color: "#00FF88" }, + badges: { flexDirection: "row", gap: 8 }, + badgeCyan: { fontSize: 11, color: "#00F5FF", fontFamily: "monospace" }, + badgePurple: { fontSize: 11, color: "#A855F7", fontFamily: "monospace" }, + resultPrompt: { + color: "rgba(255,255,255,0.8)", + fontSize: 12, + fontFamily: "monospace", + lineHeight: 18, + marginBottom: 12, + }, + improvements: { + borderTopWidth: 1, + borderTopColor: "rgba(255,255,255,0.06)", + paddingTop: 10, + }, + improvementsTitle: { fontSize: 11, color: "#A855F7", marginBottom: 6 }, + improvement: { fontSize: 11, color: "rgba(255,255,255,0.5)", lineHeight: 18 }, +}); diff --git a/apps/mobile/src/screens/PluginsScreen.tsx b/apps/mobile/src/screens/PluginsScreen.tsx new file mode 100644 index 0000000..83fbad4 --- /dev/null +++ b/apps/mobile/src/screens/PluginsScreen.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { View, Text, ScrollView, StyleSheet } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export function PluginsScreen() { + return ( + + + Plugin Marketplace + Extend PromptOS capabilities + + Plugin marketplace coming soon. + Upgrade to PRO to access premium plugins. + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#05060A" }, + scroll: { flex: 1 }, + content: { padding: 20, paddingBottom: 40 }, + title: { fontSize: 22, fontWeight: "bold", color: "#ffffff", fontFamily: "monospace", marginBottom: 2 }, + subtitle: { fontSize: 12, color: "rgba(255,255,255,0.4)", marginBottom: 20 }, + card: { + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(168,85,247,0.2)", + backgroundColor: "rgba(168,85,247,0.04)", + padding: 24, + alignItems: "center", + }, + empty: { color: "rgba(255,255,255,0.5)", fontSize: 13, marginBottom: 8 }, + hint: { color: "#A855F7", fontSize: 11 }, +}); diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..7aad057 --- /dev/null +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +const sections = [ + { + title: "Account", + items: ["Profile", "Email & Password", "Connected Wallets"], + }, + { + title: "Subscription", + items: ["Current Plan", "Billing & Invoices", "Upgrade"], + }, + { + title: "AI Settings", + items: ["Default Model", "API Keys", "Cost Limits"], + }, + { + title: "App", + items: ["Appearance", "Notifications", "Data & Privacy"], + }, +]; + +export function SettingsScreen() { + return ( + + + Settings + + {sections.map((section) => ( + + {section.title} + + {section.items.map((item, idx) => ( + + {item} + + + ))} + + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#05060A" }, + scroll: { flex: 1 }, + content: { padding: 20, paddingBottom: 40 }, + title: { fontSize: 22, fontWeight: "bold", color: "#ffffff", fontFamily: "monospace", marginBottom: 24 }, + section: { marginBottom: 20 }, + sectionTitle: { + fontSize: 10, + color: "rgba(0,245,255,0.6)", + textTransform: "uppercase", + letterSpacing: 2, + marginBottom: 8, + fontFamily: "monospace", + }, + card: { + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.08)", + backgroundColor: "rgba(255,255,255,0.04)", + overflow: "hidden", + }, + item: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 14, + }, + itemBorder: { borderBottomWidth: 1, borderBottomColor: "rgba(255,255,255,0.05)" }, + itemLabel: { fontSize: 13, color: "rgba(255,255,255,0.8)" }, + arrow: { fontSize: 18, color: "rgba(255,255,255,0.3)" }, +}); diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 0000000..3ac356c --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts", "expo-env.d.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..950bdee --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,25 @@ +# Copy from root .env.example and fill in values +NEXT_PUBLIC_APP_URL=http://localhost:3000 +JWT_SECRET=your-jwt-secret-here +ENCRYPTION_KEY=your-32-byte-encryption-key +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_PUBLISHABLE_KEY=pk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_PRICE_PRO_MONTHLY=price_xxx +STRIPE_PRICE_PRO_YEARLY=price_xxx +STRIPE_PRICE_ENTERPRISE_MONTHLY=price_xxx +STRIPE_PRICE_ENTERPRISE_YEARLY=price_xxx +OPENAI_API_KEY=sk-xxx +ANTHROPIC_API_KEY=sk-ant-xxx +GOOGLE_AI_API_KEY=xxx +FIREBASE_PROJECT_ID=xxx +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nxxx\n-----END PRIVATE KEY-----" +FIREBASE_CLIENT_EMAIL=xxx@xxx.iam.gserviceaccount.com +NEXT_PUBLIC_FIREBASE_API_KEY=xxx +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=xxx.firebaseapp.com +NEXT_PUBLIC_FIREBASE_DATABASE_URL=https://xxx-default-rtdb.firebaseio.com +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=xxx.appspot.com +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=xxx +NEXT_PUBLIC_FIREBASE_APP_ID=xxx +UPSTASH_REDIS_REST_URL=https://xxx.upstash.io +UPSTASH_REDIS_REST_TOKEN=xxx diff --git a/apps/web/next.config.js b/apps/web/next.config.js new file mode 100644 index 0000000..7e2bdc3 --- /dev/null +++ b/apps/web/next.config.js @@ -0,0 +1,21 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: [ + "@promptos/ui", + "@promptos/contracts", + "@promptos/middleware", + ], + experimental: { + serverComponentsExternalPackages: [ + "firebase-admin", + "@anthropic-ai/sdk", + "stripe", + ], + }, + images: { + domains: ["firebasestorage.googleapis.com"], + }, +}; + +module.exports = nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..744f11a --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "@promptos/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@promptos/contracts": "workspace:*", + "@promptos/services": "workspace:*", + "@promptos/middleware": "workspace:*", + "@promptos/ui": "workspace:*", + "@promptos/config": "workspace:*", + "next": "14.2.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "stripe": "^16.2.0", + "zod": "^3.23.8", + "clsx": "^2.1.1", + "tailwind-merge": "^2.3.0", + "lucide-react": "^0.395.0" + }, + "devDependencies": { + "@types/node": "^20.14.5", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-config-next": "14.2.4", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/app/api/ai/complete/route.ts b/apps/web/src/app/api/ai/complete/route.ts new file mode 100644 index 0000000..bb873ca --- /dev/null +++ b/apps/web/src/app/api/ai/complete/route.ts @@ -0,0 +1,19 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { withRateLimit } from "@promptos/middleware"; +import { aiRouterService } from "@promptos/services"; +import { AIRequestSchema } from "@promptos/contracts"; +import type { JWTPayload } from "@promptos/contracts"; + +export const POST = withRateLimit( + async (req: NextRequest, user: JWTPayload): Promise => { + const body: unknown = await req.json(); + const parsed = AIRequestSchema.safeParse({ ...(body as object), userId: user.sub }); + + if (!parsed.success) { + return NextResponse.json({ error: "Invalid AI request", issues: parsed.error.issues }, { status: 400 }); + } + + const result = await aiRouterService.complete(parsed.data); + return NextResponse.json(result); + } +); diff --git a/apps/web/src/app/api/ai/optimize/route.ts b/apps/web/src/app/api/ai/optimize/route.ts new file mode 100644 index 0000000..430e8d0 --- /dev/null +++ b/apps/web/src/app/api/ai/optimize/route.ts @@ -0,0 +1,33 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { withRateLimit } from "@promptos/middleware"; +import { aiRouterService } from "@promptos/services"; +import type { JWTPayload } from "@promptos/contracts"; + +const OptimizeSchema = z.object({ + prompt: z.string().min(1).max(10_000), + context: z.string().max(2000).optional(), + optimizationGoal: z + .enum(["clarity", "conciseness", "effectiveness", "safety"]) + .default("effectiveness"), +}); + +export const POST = withRateLimit( + async (req: NextRequest, user: JWTPayload): Promise => { + const body: unknown = await req.json(); + const parsed = OptimizeSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Invalid input", issues: parsed.error.issues }, { status: 400 }); + } + + const result = await aiRouterService.optimizePrompt({ + originalPrompt: parsed.data.prompt, + context: parsed.data.context, + optimizationGoal: parsed.data.optimizationGoal, + userId: user.sub, + }); + + return NextResponse.json(result); + } +); diff --git a/apps/web/src/app/api/billing/cancel/route.ts b/apps/web/src/app/api/billing/cancel/route.ts new file mode 100644 index 0000000..e5b5a41 --- /dev/null +++ b/apps/web/src/app/api/billing/cancel/route.ts @@ -0,0 +1,20 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { withAuth } from "@promptos/middleware"; +import { stripeService, firebaseService } from "@promptos/services"; +import type { JWTPayload } from "@promptos/contracts"; + +export const POST = withAuth( + async (req: NextRequest, user: JWTPayload): Promise => { + const userData = await firebaseService.getDocument<{ stripeSubscriptionId?: string }>( + "subscriptions", + user.sub + ); + + if (!userData?.stripeSubscriptionId) { + return NextResponse.json({ error: "No active subscription found" }, { status: 404 }); + } + + await stripeService.cancelSubscription(userData.stripeSubscriptionId); + return NextResponse.json({ message: "Subscription will cancel at period end" }); + } +); diff --git a/apps/web/src/app/api/billing/checkout/route.ts b/apps/web/src/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..797af32 --- /dev/null +++ b/apps/web/src/app/api/billing/checkout/route.ts @@ -0,0 +1,36 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { withAuth } from "@promptos/middleware"; +import { stripeService, firebaseService } from "@promptos/services"; +import { CreateCheckoutSessionSchema } from "@promptos/contracts"; +import type { JWTPayload } from "@promptos/contracts"; + +export const POST = withAuth( + async (req: NextRequest, user: JWTPayload): Promise => { + const body: unknown = await req.json(); + const parsed = CreateCheckoutSessionSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request", issues: parsed.error.issues }, { status: 400 }); + } + + const userData = await firebaseService.getDocument<{ stripeCustomerId?: string }>( + "users", + user.sub + ); + + let customerId = userData?.stripeCustomerId; + if (!customerId) { + customerId = await stripeService.createCustomer(user.sub, user.email); + await firebaseService.updateDocument("users", user.sub, { stripeCustomerId: customerId }); + } + + const appUrl = process.env["NEXT_PUBLIC_APP_URL"] ?? "http://localhost:3000"; + const checkoutUrl = await stripeService.createCheckoutSession(user.sub, customerId, { + ...parsed.data, + successUrl: `${appUrl}/billing/success`, + cancelUrl: `${appUrl}/billing`, + }); + + return NextResponse.json({ url: checkoutUrl }); + } +); diff --git a/apps/web/src/app/api/billing/portal/route.ts b/apps/web/src/app/api/billing/portal/route.ts new file mode 100644 index 0000000..ce0b209 --- /dev/null +++ b/apps/web/src/app/api/billing/portal/route.ts @@ -0,0 +1,25 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { withAuth } from "@promptos/middleware"; +import { stripeService, firebaseService } from "@promptos/services"; +import type { JWTPayload } from "@promptos/contracts"; + +export const POST = withAuth( + async (req: NextRequest, user: JWTPayload): Promise => { + const userData = await firebaseService.getDocument<{ stripeCustomerId?: string }>( + "users", + user.sub + ); + + if (!userData?.stripeCustomerId) { + return NextResponse.json({ error: "No billing account found" }, { status: 404 }); + } + + const appUrl = process.env["NEXT_PUBLIC_APP_URL"] ?? "http://localhost:3000"; + const portalUrl = await stripeService.createPortalSession( + userData.stripeCustomerId, + `${appUrl}/billing` + ); + + return NextResponse.json({ url: portalUrl }); + } +); diff --git a/apps/web/src/app/api/plugins/install/route.ts b/apps/web/src/app/api/plugins/install/route.ts new file mode 100644 index 0000000..d6bfd12 --- /dev/null +++ b/apps/web/src/app/api/plugins/install/route.ts @@ -0,0 +1,35 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { withAuth } from "@promptos/middleware"; +import { pluginEngine, firebaseService } from "@promptos/services"; +import { PluginManifestSchema } from "@promptos/contracts"; +import { z } from "zod"; +import type { JWTPayload } from "@promptos/contracts"; + +const InstallSchema = z.object({ + manifest: PluginManifestSchema, + config: z.record(z.unknown()).default({}), +}); + +export const POST = withAuth( + async (req: NextRequest, user: JWTPayload): Promise => { + const body: unknown = await req.json(); + const parsed = InstallSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Invalid plugin manifest", issues: parsed.error.issues }, { status: 400 }); + } + + const subData = await firebaseService.getDocument<{ tier: string }>("subscriptions", user.sub); + const userTier = (subData?.tier as import("@promptos/contracts").SubscriptionTier) ?? "FREE"; + + const installation = await pluginEngine.installPlugin( + user.sub, + parsed.data.manifest, + parsed.data.config, + userTier + ); + + await firebaseService.setDocument(`users/${user.sub}/plugins`, installation.pluginId, installation); + return NextResponse.json(installation, { status: 201 }); + } +); diff --git a/apps/web/src/app/api/plugins/uninstall/route.ts b/apps/web/src/app/api/plugins/uninstall/route.ts new file mode 100644 index 0000000..15530ea --- /dev/null +++ b/apps/web/src/app/api/plugins/uninstall/route.ts @@ -0,0 +1,22 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { withAuth } from "@promptos/middleware"; +import { pluginEngine, firebaseService } from "@promptos/services"; +import { z } from "zod"; +import type { JWTPayload } from "@promptos/contracts"; + +const UninstallSchema = z.object({ pluginId: z.string() }); + +export const DELETE = withAuth( + async (req: NextRequest, user: JWTPayload): Promise => { + const body: unknown = await req.json(); + const parsed = UninstallSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Missing pluginId" }, { status: 400 }); + } + + await pluginEngine.uninstallPlugin(user.sub, parsed.data.pluginId); + await firebaseService.deleteDocument(`users/${user.sub}/plugins`, parsed.data.pluginId); + return NextResponse.json({ message: "Plugin uninstalled" }); + } +); diff --git a/apps/web/src/app/api/usage/summary/route.ts b/apps/web/src/app/api/usage/summary/route.ts new file mode 100644 index 0000000..45ed1bb --- /dev/null +++ b/apps/web/src/app/api/usage/summary/route.ts @@ -0,0 +1,47 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { withAuth } from "@promptos/middleware"; +import { firebaseService } from "@promptos/services"; +import { TierLimits, SubscriptionTier } from "@promptos/contracts"; +import type { JWTPayload } from "@promptos/contracts"; + +export const GET = withAuth( + async (_req: NextRequest, user: JWTPayload): Promise => { + const subData = await firebaseService.getDocument<{ tier: string }>( + "subscriptions", + user.sub + ); + const tier = (subData?.tier as SubscriptionTier) ?? SubscriptionTier.FREE; + + const periodKey = getPeriodKey(); + const usageData = await firebaseService.getDocument<{ + tokensUsed?: number; + promptsUsed?: number; + }>(`usage/${user.sub}`, periodKey); + + const limits = TierLimits[tier]; + const tokensUsed = usageData?.tokensUsed ?? 0; + const promptsUsed = usageData?.promptsUsed ?? 0; + + return NextResponse.json({ + userId: user.sub, + tier, + current: { tokensUsed, promptsUsed }, + limits: { + monthlyTokens: limits.monthlyTokens, + monthlyPrompts: limits.monthlyPrompts, + }, + percentages: { + tokens: limits.monthlyTokens === -1 ? 0 : Math.min((tokensUsed / limits.monthlyTokens) * 100, 100), + prompts: limits.monthlyPrompts === -1 ? 0 : Math.min((promptsUsed / limits.monthlyPrompts) * 100, 100), + }, + isOverLimit: + (limits.monthlyTokens !== -1 && tokensUsed >= limits.monthlyTokens) || + (limits.monthlyPrompts !== -1 && promptsUsed >= limits.monthlyPrompts), + }); + } +); + +function getPeriodKey(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; +} diff --git a/apps/web/src/app/api/webhooks/stripe/route.ts b/apps/web/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..72bf5e8 --- /dev/null +++ b/apps/web/src/app/api/webhooks/stripe/route.ts @@ -0,0 +1,63 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { stripeService, firebaseService } from "@promptos/services"; +import { SubscriptionTier, Role, RoleTierMap } from "@promptos/contracts"; + +export async function POST(req: NextRequest): Promise { + const payload = await req.text(); + const sig = req.headers.get("stripe-signature"); + + if (!sig) { + return NextResponse.json({ error: "Missing stripe signature" }, { status: 400 }); + } + + let event; + try { + event = await stripeService.constructWebhookEvent(payload, sig); + } catch { + return NextResponse.json({ error: "Invalid webhook signature" }, { status: 400 }); + } + + try { + const result = await stripeService.handleWebhookEvent(event); + + if (result.userId && result.action !== "unhandled") { + switch (result.action) { + case "subscription_created": + case "subscription_updated": { + const tier = result.tier ?? SubscriptionTier.FREE; + const role = Object.entries(RoleTierMap).find(([, t]) => t === tier)?.[0] as Role | undefined; + + await firebaseService.setDocument("subscriptions", result.userId, { + tier, + stripeSubscriptionId: result.subscriptionId, + status: result.status ?? "active", + updatedAt: new Date(), + }); + + if (role) { + await firebaseService.setCustomClaims(result.userId, { role, tier }); + } + break; + } + case "subscription_canceled": { + await firebaseService.setDocument("subscriptions", result.userId, { + tier: SubscriptionTier.FREE, + stripeSubscriptionId: null, + status: "canceled", + updatedAt: new Date(), + }); + await firebaseService.setCustomClaims(result.userId, { + role: Role.USER, + tier: SubscriptionTier.FREE, + }); + break; + } + } + } + + return NextResponse.json({ received: true }); + } catch (err) { + console.error("Webhook processing error:", err); + return NextResponse.json({ error: "Webhook processing failed" }, { status: 500 }); + } +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx new file mode 100644 index 0000000..0d08fbc --- /dev/null +++ b/apps/web/src/app/dashboard/page.tsx @@ -0,0 +1,78 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { title: "Dashboard – PromptOS" }; + +const stats = [ + { label: "Prompts Created", value: "0", change: "+0%", color: "#00F5FF" }, + { label: "Tokens Used", value: "0", change: "+0%", color: "#A855F7" }, + { label: "API Calls", value: "0", change: "+0%", color: "#00FF88" }, + { label: "Cost (USD)", value: "$0.00", change: "+0%", color: "#FF0090" }, +]; + +const quickActions = [ + { label: "New Prompt", href: "/optimizer", icon: "✦", color: "#00F5FF" }, + { label: "Plugins", href: "/plugins", icon: "⬡", color: "#A855F7" }, + { label: "API Keys", href: "/settings/api", icon: "⚿", color: "#00FF88" }, + { label: "Billing", href: "/billing", icon: "◈", color: "#FF0090" }, +]; + +export default function DashboardPage() { + return ( +
+
+
+

+ Command Center +

+

PromptOS Dashboard

+
+ + ← Home + +
+ +
+ {stats.map((stat) => ( +
+

{stat.label}

+

+ {stat.value} +

+

{stat.change} this month

+
+ ))} +
+ +
+ {quickActions.map((action) => ( + + + {action.icon} + + + {action.label} + + + ))} +
+ +
+

Recent Activity

+
+ No activity yet. Create your first prompt to get started. +
+
+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..284fddd --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,51 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --neon-cyan: #00F5FF; + --electric-purple: #A855F7; + --deep-space: #05060A; + --neon-green: #00FF88; + } + + * { + border-color: rgba(0, 245, 255, 0.1); + } + + ::selection { + background-color: rgba(0, 245, 255, 0.2); + color: #fff; + } + + ::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + ::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.03); + } + + ::-webkit-scrollbar-thumb { + background: rgba(0, 245, 255, 0.3); + border-radius: 2px; + } +} + +@layer components { + .glass { + @apply backdrop-blur-[12px] bg-[linear-gradient(135deg,rgba(255,255,255,0.07)_0%,rgba(255,255,255,0.02)_100%)]; + @apply border border-[rgba(0,245,255,0.2)]; + @apply shadow-[0_8px_32px_rgba(0,0,0,0.4),inset_0_1px_0_rgba(255,255,255,0.1)]; + } + + .neon-text-cyan { + text-shadow: 0 0 10px #00F5FF, 0 0 20px #00F5FF; + } + + .neon-text-purple { + text-shadow: 0 0 10px #A855F7, 0 0 20px #A855F7; + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..46a73bd --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata, Viewport } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "PromptOS – AI Command Center", + description: "The cyber-futuristic AI prompt management and optimization platform", + keywords: ["AI", "prompts", "LLM", "GPT", "Claude", "Gemini"], +}; + +export const viewport: Viewport = { + themeColor: "#05060A", + colorScheme: "dark", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + +
+
+ {children} + + + ); +} diff --git a/apps/web/src/app/optimizer/page.tsx b/apps/web/src/app/optimizer/page.tsx new file mode 100644 index 0000000..1da9a83 --- /dev/null +++ b/apps/web/src/app/optimizer/page.tsx @@ -0,0 +1,113 @@ +"use client"; +import { useState } from "react"; +import Link from "next/link"; + +export default function OptimizerPage() { + const [prompt, setPrompt] = useState(""); + const [result, setResult] = useState<{ + optimizedPrompt: string; + improvements: string[]; + scoreImprovement: number; + tokensReduced: number; + } | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleOptimize() { + if (!prompt.trim()) return; + setLoading(true); + setError(null); + try { + const res = await fetch("/api/ai/optimize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: prompt.trim() }), + }); + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? "Optimization failed"); + } + const data = (await res.json()) as typeof result; + setResult(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

+ Prompt Optimizer +

+

AI-powered prompt enhancement

+
+ + ← Dashboard + +
+ +
+ +