From abfb7d7c543bc78c5702461f183db7cd60970e5f Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 29 Jul 2025 12:27:59 +0000 Subject: [PATCH] Stripe integration + BillingResult --- .env | 5 +- .env.development | 42 ------ .env.staging | 42 ------ .gitignore | 2 + Dockerfile.server3 | 26 ++-- backend/server1.js | 6 +- backend/server3.js | 213 +++++++++++++++++++++++++++--- backend/utils/authenticateUser.js | 4 +- deploy_all.sh | 72 ++++++++++ docker-compose.yml | 65 +++++++-- package-lock.json | 15 ++- package.json | 1 + playwright.config.js | 6 + src/App.js | 4 +- src/components/BillingResult.js | 66 +++++++++ src/components/CareerRoadmap.js | 1 - src/components/Paywall.js | 161 ++++++++++++++-------- 17 files changed, 545 insertions(+), 186 deletions(-) delete mode 100755 .env.development delete mode 100644 .env.staging create mode 100755 deploy_all.sh create mode 100644 playwright.config.js create mode 100644 src/components/BillingResult.js diff --git a/.env b/.env index ea67c9e..88e706c 100644 --- a/.env +++ b/.env @@ -1,6 +1,5 @@ -IMG_TAG=20250716 +CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000 SERVER1_PORT=5000 SERVER2_PORT=5001 SERVER3_PORT=5002 -SALARY_DB=/salary_info.db -NODE_ENV=production \ No newline at end of file +IMG_TAG=202507281838 \ No newline at end of file diff --git a/.env.development b/.env.development deleted file mode 100755 index 78c234e..0000000 --- a/.env.development +++ /dev/null @@ -1,42 +0,0 @@ -# ─── O*NET ─────────────────────────────── -ONET_USERNAME=aptivaai -ONET_PASSWORD=2296ahq - -# ─── Public‐facing React build ─────────── -NODE_ENV=development -REACT_APP_ENV=development -APTIVA_API_BASE=https://dev1.aptivaai.com -REACT_APP_API_URL=${APTIVA_API_BASE} -REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20 -REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231 -REACT_APP_OPENAI_API_KEY=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 - -# ─── Back-end services ─────────────────── -OPENAI_API_KEY=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 -GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20 -COLLEGE_SCORECARD_KEY=BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj -SALARY_DB=/salary_info.db - -# ─── Database (premium server) ─────────── -DB_HOST=34.67.180.54 -DB_PORT=3306 -DB_USER=sqluser -DB_PASSWORD=ps /app/.env.production -dotenv.config({ path: envPath }); -const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api"; +if (!process.env.FROM_SECRETS_MANAGER) { + dotenv.config({ path: envPath }); +} + +const PORT = process.env.SERVER3_PORT || 5002; +const API_BASE = `http://localhost:${PORT}/api`; + +/* ─── helper: canonical public origin ─────────────────────────── */ +const PUBLIC_BASE = ( + process.env.APTIVA_AI_BASE // ← preferred + || process.env.REACT_APP_API_URL // ← old name, tolerated + || '' +).replace(/\/+$/, ''); // strip trailing “/” + +/* allow‑list for redirects to block open‑redirect attacks */ +const ALLOWED_REDIRECT_HOSTS = new Set([ + new URL(PUBLIC_BASE || 'http://localhost').host +]); + +function isSafeRedirect(url) { + try { + const u = new URL(url); + return ALLOWED_REDIRECT_HOSTS.has(u.host) && u.protocol === 'https:'; + } catch { return false; } + } const app = express(); -const PORT = process.env.SERVER3_PORT || 5002; const { getDocument } = pkg; const bt = "`".repeat(3); -function internalFetch(req, url, opts = {}) { - return fetch(url, { +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2024-04-10', +}); + +// at top of backend/server.js (do once per server codebase) +app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content + +function internalFetch(req, urlPath, opts = {}) { + return fetch(`${API_BASE}${urlPath}`, { ...opts, headers: { "Content-Type": "application/json", @@ -44,6 +75,58 @@ function internalFetch(req, url, opts = {}) { }); } + +app.post('/api/premium/stripe/webhook', + express.raw({ type: 'application/json' }), + async (req, res) => { + + let event; + try { + event = stripe.webhooks.constructEvent( + req.body, + req.headers['stripe-signature'], + process.env.STRIPE_WH_SECRET + ); + } catch (err) { + console.error('⚠️ Bad Stripe signature', err.message); + return res.status(400).end(); + } + + const upFlags = async (customerId, premium, pro) => { + console.log('[Stripe] upFlags ->', { customerId, premium, pro}); + await pool.query( + `UPDATE user_profile + SET is_premium = ?, is_pro_premium = ? + WHERE stripe_customer_id = ?`, + [premium, pro, customerId] + ); + }; + + switch (event.type) { + case 'customer.subscription.created': + case 'customer.subscription.updated': { + const sub = event.data.object; + const pid = sub.items.data[0].price.id; + const tier = [process.env.STRIPE_PRICE_PRO_MONTH, process.env.STRIPE_PRICE_PRO_YEAR] + .includes(pid) ? 'pro' : 'premium'; + await upFlags(sub.customer, tier === 'premium', tier === 'pro'); + break; + console.log('[Stripe] flags updated', { id: sub.customer, tier }); + } + case 'customer.subscription.deleted': { + const sub = event.data.object; + await upFlags(sub.customer, 0, 0); + break; + } + default: + // ignore everything else + } + + res.status(200).end(); + } +); + + // 2) Basic middlewares app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false })); app.use(express.json({ limit: '5mb' })); @@ -53,10 +136,6 @@ if (!process.env.CORS_ALLOWED_ORIGINS) { console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); process.exit(1); } -if (!process.env.APTIVA_API_BASE) { - console.error('FATAL APTIVA_API_BASE is not set'); - process.exit(1); -} /* ─── Allowed origins for CORS (comma-separated in env) ─────── */ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS @@ -115,8 +194,54 @@ const authenticatePremiumUser = (req, res, next) => { const pool = db; -// at top of backend/server.js (do once per server codebase) -app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content + +/** ------------------------------------------------------------------ + * Returns the user’s stripe_customer_id (or null) given req.id. + * Creates a new Stripe Customer & saves it if missing. + * ----------------------------------------------------------------- */ +async function getOrCreateStripeCustomerId(req) { + // 1) look up current row + const [[row]] = await pool.query( + 'SELECT stripe_customer_id FROM user_profile WHERE id = ?', + [req.id] + ); + if (row?.stripe_customer_id) return row.stripe_customer_id; + + // 2) create → cache → return + const customer = await stripe.customers.create({ metadata: { userId: req.id } }); + await pool.query( + 'UPDATE user_profile SET stripe_customer_id = ? WHERE id = ?', + [customer.id, req.id] + ); + return customer.id; +} + +const priceMap = { + premium: { monthly: process.env.STRIPE_PRICE_PREMIUM_MONTH, + annual : process.env.STRIPE_PRICE_PREMIUM_YEAR }, + pro : { monthly: process.env.STRIPE_PRICE_PRO_MONTH, + annual : process.env.STRIPE_PRICE_PRO_YEAR } +}; + +app.get('/api/premium/subscription/status', authenticatePremiumUser, async (req, res) => { + try { + const [[row]] = await pool.query( + 'SELECT is_premium, is_pro_premium FROM user_profile WHERE id = ?', + [req.id] + ); + + if (!row) return res.status(404).json({ error: 'User not found' }); + + return res.json({ + is_premium : !!row.is_premium, + is_pro_premium : !!row.is_pro_premium + }); + } catch (err) { + console.error('subscription/status error:', err); + return res.status(500).json({ error: 'DB error' }); + } +}); + /* ======================================================================== * applyOps – executes the “milestones” array inside a fenced ```ops block @@ -125,11 +250,10 @@ app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content async function applyOps(opsObj, req) { if (!opsObj?.milestones || !Array.isArray(opsObj.milestones)) return []; - const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api"; const confirmations = []; // helper for authenticated fetches that keep headers - const auth = (path, opts = {}) => internalFetch(req, `${apiBase}${path}`, opts); + const auth = (path, opts = {}) => internalFetch(req, path, opts); for (const m of opsObj.milestones) { const { op } = m || {}; @@ -915,9 +1039,9 @@ ${econText} const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api"; let aiRisk = null; try { - const aiRiskRes = await internalFetch( + const aiRiskRes = await auth( req, - `${apiBase}/premium/ai-risk-analysis`, + '/premium/ai-risk-analysis', { method: "POST", body: JSON.stringify({ @@ -1289,7 +1413,7 @@ if (embeddedJson) { // <── instead of startsWith("{")… }; // Call your existing milestone endpoint - const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, { + const msRes = await auth(req, '/premium/milestone', { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(milestoneBody) @@ -1324,7 +1448,7 @@ if (embeddedJson) { // <── instead of startsWith("{")… due_date: taskObj.due_date || null }; - await internalFetch(req, `${apiBase}/premium/tasks`, { + await auth(req, '/premium/tasks', { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(taskBody) @@ -1358,7 +1482,7 @@ if (embeddedJson) { // <── instead of startsWith("{")… end_date: impObj.end_date || null }; - await internalFetch(req, `${apiBase}/premium/milestone-impacts`, { + await auth(req, '/premium/milestone-impacts', { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(impactBody) @@ -1531,7 +1655,7 @@ Always end with: “AptivaAI is an educational tool – not advice.” if (payloadObj?.cloneScenario) { /* ------ CLONE ------ */ - await internalFetch(req, `${apiBase}/premium/career-profile/clone`, { + await auth(req, '/premium/career-profile/clone', { method: 'POST', body : JSON.stringify(payloadObj.cloneScenario), headers: { 'Content-Type': 'application/json' } @@ -3798,6 +3922,57 @@ app.post('/api/premium/reminders', authenticatePremiumUser, async (req, res) => } }); +app.post('/api/premium/stripe/create-checkout-session', + authenticatePremiumUser, + async (req, res) => { + const { tier = 'premium', cycle = 'monthly', success_url, cancel_url } = + req.body || {}; + + const priceId = priceMap?.[tier]?.[cycle]; + if (!priceId) return res.status(400).json({ error: 'Bad tier or cycle' }); + + const customerId = await getOrCreateStripeCustomerId(req); + + const base = PUBLIC_BASE || `https://${req.headers.host}`; + const defaultSuccess = `${base}/billing?ck=success`; + const defaultCancel = `${base}/billing?ck=cancel`; + + const safeSuccess = success_url && isSafeRedirect(success_url) + ? success_url : defaultSuccess; + const safeCancel = cancel_url && isSafeRedirect(cancel_url) + ? cancel_url : defaultCancel; + + const session = await stripe.checkout.sessions.create({ + mode : 'subscription', + customer : customerId, + line_items : [{ price: priceId, quantity: 1 }], + allow_promotion_codes : true, + success_url : safeSuccess, + cancel_url : safeCancel + }); + + res.json({ url: session.url }); + } +); + +app.get('/api/premium/stripe/customer-portal', + authenticatePremiumUser, + async (req, res) => { + const base = PUBLIC_BASE || `https://${req.headers.host}`; + const { return_url } = req.query; + const safeReturn = return_url && isSafeRedirect(return_url) + ? return_url + : `${base}/billing`; + const cid = await getOrCreateStripeCustomerId(req); // never null now + + const portal = await stripe.billingPortal.sessions.create({ + customer : cid, + return_url + }); + res.json({ url: portal.url }); + } +); + /* ------------------------------------------------------------------ FALLBACK 404 ------------------------------------------------------------------ */ diff --git a/backend/utils/authenticateUser.js b/backend/utils/authenticateUser.js index ab4e370..46d200e 100644 --- a/backend/utils/authenticateUser.js +++ b/backend/utils/authenticateUser.js @@ -1,5 +1,5 @@ import jwt from "jsonwebtoken"; -const SECRET_KEY = process.env.SECRET_KEY || "supersecurekey"; +const JWT_SECRET = process.env.JWT_SECRET; /** * Adds `req.user = { id: }` @@ -10,7 +10,7 @@ export default function authenticateUser(req, res, next) { if (!token) return res.status(401).json({ error: "Authorization token required" }); try { - const { id } = jwt.verify(token, SECRET_KEY); + const { id } = jwt.verify(token, JWT_SECRET); req.user = { id }; // attach the id for downstream use next(); } catch (err) { diff --git a/deploy_all.sh b/deploy_all.sh new file mode 100755 index 0000000..f15512e --- /dev/null +++ b/deploy_all.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ───────────────────────────────────────────────────────────── +# CONFIG – adjust only the 4 lines below if you change projects +# ───────────────────────────────────────────────────────────── +ENV=dev # secret suffix, e.g. JWT_SECRET_staging +PROJECT=aptivaai-dev +ROOT=/home/jcoakley/aptiva-dev1-app +REG=us-central1-docker.pkg.dev/${PROJECT}/aptiva-repo + +ENV_FILE="${ROOT}/.env" # ← holds NON‑sensitive values only +SECRETS=( + JWT_SECRET OPENAI_API_KEY ONET_USERNAME ONET_PASSWORD + STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY STRIPE_WH_SECRET STRIPE_PRICE_PREMIUM_MONTH STRIPE_PRICE_PREMIUM_YEAR STRIPE_PRICE_PRO_MONTH STRIPE_PRICE_PRO_YEAR + DB_HOST DB_PORT DB_USER DB_PASSWORD + TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID +) + +cd "$ROOT" +echo "🛠 Building front‑end bundle" +npm ci --silent # installs if node_modules is missing/old +npm run build + +# ───────────────────────────────────────────────────────────── +# 1. Build ➔ Push ➔ Bump IMG_TAG in .env +# ───────────────────────────────────────────────────────────── +TAG=$(date -u +%Y%m%d%H%M) +echo "🔨 Building & pushing containers (tag = ${TAG})" + +for svc in server1 server2 server3; do + docker build -f Dockerfile."$svc" -t "${REG}/${svc}:${TAG}" . + docker push "${REG}/${svc}:${TAG}" +done + +# keep .env for static, non‑sensitive keys (ports, API_BASE…) +if grep -q '^IMG_TAG=' "$ENV_FILE"; then + sed -i "s/^IMG_TAG=.*/IMG_TAG=${TAG}/" "$ENV_FILE" +else + echo "IMG_TAG=${TAG}" >> "$ENV_FILE" +fi +echo "✅ .env updated with IMG_TAG=${TAG}" + +# ───────────────────────────────────────────────────────────── +# 2. Export secrets straight from Secret Manager +# (they live only in this shell, never on disk) +# ───────────────────────────────────────────────────────────── +echo "🔐 Pulling ${ENV} secrets from Secret Manager" +for S in "${SECRETS[@]}"; do + export "$S"="$(gcloud secrets versions access latest \ + --secret="${S}_${ENV}" \ + --project="$PROJECT")" +done + +# A flag so we can see in the container env where they came from +export FROM_SECRETS_MANAGER=true + +# ───────────────────────────────────────────────────────────── +# 3. Re‑create the stack +# ───────────────────────────────────────────────────────────── +# Preserve only the variables docker‑compose needs for expansion +preserve=IMG_TAG,FROM_SECRETS_MANAGER,REACT_APP_API_URL,$(IFS=,; echo "${SECRETS[*]}") + + +echo "🚀 docker compose up -d (with preserved env: $preserve)" +sudo --preserve-env="$preserve" docker compose up -d --force-recreate 2> >(grep -v 'WARN + +\[0000\] + +') + +echo "✅ Deployment finished" diff --git a/docker-compose.yml b/docker-compose.yml index db7ce11..eef8893 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,28 @@ +# --------------------------------------------------------------------------- +# A single env‑file (.env) contains ONLY non‑secret constants. +# Every secret is exported from fetch‑secrets.sh and injected at deploy time. +# --------------------------------------------------------------------------- x-env: &with-env env_file: - - ${RUNTIME_ENV_FILE:-.env.production} # default for local runs + - .env # committed, non‑secret restart: unless-stopped services: + # ───────────────────────────── server1 ───────────────────────────── server1: <<: *with-env image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG} expose: ["${SERVER1_PORT}"] environment: + JWT_SECRET: ${JWT_SECRET} + DB_HOST: ${DB_HOST} + DB_PORT: ${DB_PORT} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} SALARY_DB_PATH: /app/salary_info.db + FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} volumes: - ./salary_info.db:/app/salary_info.db:ro - ./user_profile.db:/app/user_profile.db @@ -19,14 +32,26 @@ services: timeout: 5s retries: 3 + # ───────────────────────────── server2 ───────────────────────────── server2: + <<: *with-env image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG} expose: ["${SERVER2_PORT}"] - restart: unless-stopped - env_file: - - ${RUNTIME_ENV_FILE} + environment: + ONET_USERNAME: ${ONET_USERNAME} + ONET_PASSWORD: ${ONET_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + OPENAI_API_KEY: ${OPENAI_API_KEY} + DB_HOST: ${DB_HOST} + DB_PORT: ${DB_PORT} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + SALARY_DB_PATH: /app/salary_info.db + FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} volumes: - - ./public:/app/public:ro + - ./public:/app/public:ro - ./salary_info.db:/app/salary_info.db:ro - ./user_profile.db:/app/user_profile.db healthcheck: @@ -35,24 +60,48 @@ services: timeout: 5s retries: 3 + # ───────────────────────────── server3 ───────────────────────────── server3: <<: *with-env image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG} expose: ["${SERVER3_PORT}"] + environment: + JWT_SECRET: ${JWT_SECRET} + OPENAI_API_KEY: ${OPENAI_API_KEY} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} + STRIPE_WH_SECRET: ${STRIPE_WH_SECRET} + STRIPE_PRICE_PREMIUM_MONTH: ${STRIPE_PRICE_PREMIUM_MONTH} + STRIPE_PRICE_PREMIUM_YEAR: ${STRIPE_PRICE_PREMIUM_YEAR} + STRIPE_PRICE_PRO_MONTH: ${STRIPE_PRICE_PRO_MONTH} + STRIPE_PRICE_PRO_YEAR: ${STRIPE_PRICE_PRO_YEAR} + TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID} + TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN} + TWILIO_MESSAGING_SERVICE_SID: ${TWILIO_MESSAGING_SERVICE_SID} + DB_HOST: ${DB_HOST} + DB_PORT: ${DB_PORT} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + SALARY_DB_PATH: /app/salary_info.db + FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} + volumes: + - ./salary_info.db:/app/salary_info.db:ro + - ./user_profile.db:/app/user_profile.db healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"] interval: 30s timeout: 5s retries: 3 + # ───────────────────────────── nginx ─────────────────────────────── nginx: <<: *with-env image: nginx:1.25-alpine command: ["nginx", "-g", "daemon off;"] depends_on: [server1, server2, server3] - ports: - - "80:80" - - "443:443" + ports: ["80:80", "443:443"] volumes: - ./build:/usr/share/nginx/html:ro - ./nginx.conf:/etc/nginx/nginx.conf:ro diff --git a/package-lock.json b/package-lock.json index 543d6fd..b60a214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "aptiva-dev1-app", "version": "0.1.0", - "hasInstallScript": true, "license": "ISC", "dependencies": { "@radix-ui/react-dialog": "^1.0.0", @@ -53,6 +52,7 @@ "react-spinners": "^0.15.0", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", + "stripe": "^14.0.0", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", "twilio": "^5.7.1", @@ -18715,6 +18715,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "14.25.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz", + "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", diff --git a/package.json b/package.json index bf802c4..13b6175 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "react-spinners": "^0.15.0", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", + "stripe": "^14.0.0", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", "twilio": "^5.7.1", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..79af517 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,6 @@ +const { defineConfig } = require('@playwright/test'); +module.exports = defineConfig({ + testDir: 'tests', + projects:[ {name:'chromium', use:{browserName:'chromium'}} ], + timeout: 30000, +}); diff --git a/src/App.js b/src/App.js index 6ee56d9..314fd94 100644 --- a/src/App.js +++ b/src/App.js @@ -38,6 +38,7 @@ import LoanRepaymentPage from './components/LoanRepaymentPage.js'; import usePageContext from './utils/usePageContext.js'; import ChatDrawer from './components/ChatDrawer.js'; import ChatCtx from './contexts/ChatCtx.js'; +import BillingResult from './components/BillingResult.js'; @@ -222,7 +223,7 @@ const uiToolHandlers = useMemo(() => { { setDrawerPane('support'); setDrawerOpen(true); }, @@ -523,6 +524,7 @@ const uiToolHandlers = useMemo(() => { }/> } /> } /> + } /> {/* Premium-only routes */} { + const token = localStorage.getItem('token') || ''; + fetch('/api/user-profile', { headers: { Authorization: `Bearer ${token}` } }) + .then(r => r.ok ? r.json() : null) + .then(profile => { if (profile && setUser) setUser(profile); }) + .finally(() => setLoading(false)); + }, [setUser]); + + /* ───────────────────────────────────────────────────────── + 2) UX while waiting for that round‑trip + ───────────────────────────────────────────────────────── */ + if (loading) { + return

Checking your subscription…

; + } + + /* ───────────────────────────────────────────────────────── + 3) Success – Stripe completed the checkout flow + ───────────────────────────────────────────────────────── */ + if (outcome === 'success') { + return ( +
+

🎉 Subscription activated!

+

+ Premium features have been unlocked on your account. +

+ + + + +
+ ); + } + + /* ───────────────────────────────────────────────────────── + 4) Cancelled – user backed out of Stripe + ───────────────────────────────────────────────────────── */ + return ( +
+

Subscription cancelled

+

No changes were made to your account.

+ + +
+ ); +} diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index 8417dfd..6927ef1 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -959,7 +959,6 @@ useEffect(() => { // 8) Build financial projection async function buildProjection(milestones) { - if (!milestones?.length) return; const allMilestones = milestones || []; try { setScenarioMilestones(allMilestones); diff --git a/src/components/Paywall.js b/src/components/Paywall.js index dc2b827..b8636a6 100644 --- a/src/components/Paywall.js +++ b/src/components/Paywall.js @@ -1,66 +1,121 @@ -import React from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { Button } from './ui/button.js'; +// src/components/Paywall.jsx +import { useEffect, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from './ui/button.js'; -const Paywall = () => { - const navigate = useNavigate(); - const { state } = useLocation(); +export default function Paywall() { + const nav = useNavigate(); + const [sub, setSub] = useState(null); // null = loading + const token = localStorage.getItem('token') || ''; - const { - redirectTo = '/premium-onboarding', // wizard by default - prevState = {}, // any custom state we passed - selectedCareer - } = state || {}; + /* ───────────────── fetch current subscription ─────────────── */ + useEffect(() => { + fetch('/api/premium/subscription/status', { + headers: { Authorization: `Bearer ${token}` } + }) + .then(r => r.ok ? r.json() : Promise.reject(r.status)) + .then(setSub) + .catch(() => setSub({ is_premium:0, is_pro_premium:0 })); + }, [token]); - const handleSubscribe = async () => { - const token = localStorage.getItem('token'); - if (!token) return navigate('/signin'); - - try { - const res = await fetch('/api/activate-premium', { - method: 'POST', - headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` } + /* ───────────────── helpers ────────────────────────────────── */ + const checkout = useCallback(async (tier, cycle) => { + const base = window.location.origin; // https://dev1.aptivaai.com + const res = await fetch('/api/premium/stripe/create-checkout-session', { + method : 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization : `Bearer ${token}` + }, + body: JSON.stringify({ + tier, + cycle, + success_url: `${base}/billing?ck=success`, + cancel_url : `${base}/billing?ck=cancel` + }) }); + if (!res.ok) return console.error('Checkout failed', await res.text()); - if (res.status === 401) return navigate('/signin-landing'); + const { url } = await res.json(); + window.location.href = url; // redirect to Stripe + }, [token]); - if (res.ok) { - // 1) grab the fresh token / profile if the API returns it - const { token: newToken, user } = await res.json().catch(() => ({})); - if (newToken) localStorage.setItem('token', newToken); - if (user) window.dispatchEvent(new Event('user-updated')); // or your context setter + const openPortal = useCallback(async () => { + const base = window.location.origin; + const res = await fetch(`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) return console.error('Portal error', await res.text()); + window.location.href = (await res.json()).url; + }, [token]); - // 2) give the auth context time to update, then push - navigate(redirectTo, { replace: true, state: prevState }); - } else { - console.error('activate-premium failed:', await res.text()); - } - } catch (err) { - console.error('Error activating premium:', err); + /* ───────────────── render ─────────────────────────────────── */ + if (!sub) return

Loading …

; + + if (sub.is_premium || sub.is_pro_premium) { + const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium'; + + return ( +
+

Your plan: {plan}

+

+ Manage payment method, invoices or cancel anytime. +

+ + + + +
+ ); } -}; - + /* ─── no active sub => show the pricing choices ──────────────── */ return ( -
-

Unlock AptivaAI Premium

-
    -
  • ✅ Personalized Career Milestone Planning
  • -
  • ✅ Comprehensive Financial Projections
  • -
  • ✅ Resume & Interview Assistance
  • -
+
+
+

Upgrade to AptivaAI

+

+ Choose the plan that fits your needs – cancel anytime. +

+
- - - + {/* Premium tier */} +
+

Premium

+
    +
  • Career milestone planning
  • +
  • Financial projections & benchmarks
  • +
  • 2 × resume optimizations / week
  • +
+ +
+ + +
+
+ + {/* Pro tier */} +
+

Pro Premium

+
    +
  • Everything in Premium
  • +
  • Priority GPT‑4o usage & higher rate limits
  • +
  • 5 × resume optimizations / week
  • +
+ +
+ + +
+
+ +
); -}; - -export default Paywall; +}