Stripe integration + BillingResult
This commit is contained in:
parent
bd8a40419d
commit
abfb7d7c54
5
.env
5
.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
|
||||
IMG_TAG=202507281838
|
@ -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<g+2DO-eTb2mb5
|
||||
DB_NAME=user_profile_db
|
||||
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
|
||||
|
||||
# ── Twilio (needed only by server3) ─────────────────────────
|
||||
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
|
||||
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
|
||||
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
|
||||
|
||||
# ─── Anything new goes here ──────────────
|
||||
JWT_SECRET=gW4QsOu4AJA4MooIUC9ld2i71VbBovzV1INsaU6ftxYPrxLIeMq6/OY61j0X2RV7
|
||||
|
||||
# ------------ CORS ------------
|
||||
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
|
||||
|
||||
IMG_TAG=20250716
|
42
.env.staging
42
.env.staging
@ -1,42 +0,0 @@
|
||||
# ─── O*NET ───────────────────────────────
|
||||
ONET_USERNAME=aptivaai
|
||||
ONET_PASSWORD=2296ahq
|
||||
|
||||
# ─── Public‐facing React build ───────────
|
||||
NODE_ENV=production
|
||||
REACT_APP_ENV=staging
|
||||
APTIVA_API_BASE=https://staging.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<g+2DO-eTb2mb5
|
||||
DB_NAME=user_profile_db
|
||||
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
|
||||
|
||||
# ── Twilio (needed only by server3) ─────────────────────────
|
||||
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
|
||||
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
|
||||
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
|
||||
|
||||
# ─── Anything new goes here ──────────────
|
||||
JWT_SECRET=a35F0iFAkkdWvSjnaLzepAl/JIxPRUh4NpcGptJgry2Z3KVLX4ZcYY5KaTf7kJY0
|
||||
|
||||
# ------------ env/staging.env ------------
|
||||
CORS_ALLOWED_ORIGINS=https://staging.aptivaai.com,http://34.61.84.49:3000,http://localhost:3000
|
||||
SERVER1_PORT=5000
|
||||
SERVER2_PORT=5001
|
||||
SERVER3_PORT=5002
|
||||
|
||||
IMG_TAG=20250716
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -21,3 +21,5 @@ yarn-error.log*
|
||||
.bashrc
|
||||
_logout
|
||||
env/*.env
|
||||
*.env
|
||||
uploads/
|
@ -1,12 +1,20 @@
|
||||
ARG APPPORT=5002
|
||||
FROM node:20-slim
|
||||
# ---- Dockerfile.server3 (fixed) ------------------------------
|
||||
FROM node:20-bullseye
|
||||
WORKDIR /app
|
||||
|
||||
# 1. native build dependencies + curl
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential python3 pkg-config curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. node deps
|
||||
COPY package*.json ./
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y build-essential python3 make g++ --no-install-recommends python3 git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
RUN npm ci --omit=dev --unsafe-perm
|
||||
|
||||
# 3. static assets & source
|
||||
COPY public/ /app/public/
|
||||
COPY . .
|
||||
ENV PORT=5002
|
||||
EXPOSE 5002
|
||||
|
||||
CMD ["node", "backend/server3.js"]
|
||||
|
||||
|
@ -18,6 +18,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const rootPath = path.resolve(__dirname, '..'); // Up one level
|
||||
const env = process.env.NODE_ENV?.trim() || 'development';
|
||||
const stage = env === 'staging' ? 'development' : env;
|
||||
const envPath = path.resolve(rootPath, `.env.${env}`);
|
||||
dotenv.config({ path: envPath }); // Load .env file
|
||||
|
||||
@ -34,7 +35,6 @@ if (!JWT_SECRET) {
|
||||
process.exit(1); // container exits, Docker marks it unhealthy
|
||||
}
|
||||
|
||||
|
||||
// Create a MySQL pool for user_profile data
|
||||
const pool = mysql.createPool({
|
||||
host: DB_HOST,
|
||||
@ -62,10 +62,6 @@ if (!process.env.CORS_ALLOWED_ORIGINS) {
|
||||
console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); // eslint-disable-line
|
||||
process.exit(1);
|
||||
}
|
||||
if (!process.env.APTIVA_API_BASE) {
|
||||
console.error('FATAL APTIVA_API_BASE is not set'); // eslint-disable-line
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
|
||||
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||||
|
@ -19,22 +19,53 @@ import db from './config/mysqlPool.js';
|
||||
import './jobs/reminderCron.js';
|
||||
import OpenAI from 'openai';
|
||||
import Fuse from 'fuse.js';
|
||||
import Stripe from 'stripe';
|
||||
import { createReminder } from './utils/smsService.js';
|
||||
import { cacheSummary } from "./utils/ctxCache.js";
|
||||
|
||||
const rootPath = path.resolve(__dirname, '..'); // one level up
|
||||
const env = (process.env.NODE_ENV || 'production'); // production in prod
|
||||
const stage = env === 'staging' ? 'development' : env;
|
||||
const envPath = path.resolve(rootPath, `.env.${env}`); // => /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
|
||||
------------------------------------------------------------------ */
|
||||
|
@ -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: <user_profile.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) {
|
||||
|
72
deploy_all.sh
Executable file
72
deploy_all.sh
Executable file
@ -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"
|
@ -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,12 +32,24 @@ 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
|
||||
- ./salary_info.db:/app/salary_info.db:ro
|
||||
@ -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
|
||||
|
15
package-lock.json
generated
15
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
6
playwright.config.js
Normal file
6
playwright.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({
|
||||
testDir: 'tests',
|
||||
projects:[ {name:'chromium', use:{browserName:'chromium'}} ],
|
||||
timeout: 30000,
|
||||
});
|
@ -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(() => {
|
||||
<ProfileCtx.Provider
|
||||
value={{ financialProfile, setFinancialProfile,
|
||||
scenario, setScenario,
|
||||
user, }}
|
||||
user, setUser}}
|
||||
>
|
||||
<ChatCtx.Provider value={{ setChatSnapshot,
|
||||
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
|
||||
@ -523,6 +524,7 @@ const uiToolHandlers = useMemo(() => {
|
||||
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/>
|
||||
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
|
||||
<Route path="/preparing" element={<PreparingLanding />} />
|
||||
<Route path="/billing" element={<BillingResult />} />
|
||||
|
||||
{/* Premium-only routes */}
|
||||
<Route
|
||||
|
66
src/components/BillingResult.js
Normal file
66
src/components/BillingResult.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { useEffect, useState, useContext } from 'react';
|
||||
import { useLocation, Link } from 'react-router-dom';
|
||||
import { Button } from './ui/button.js';
|
||||
import { ProfileCtx } from '../App.js'; // <- exported at very top of App.jsx
|
||||
|
||||
export default function BillingResult() {
|
||||
const { setUser } = useContext(ProfileCtx) || {};
|
||||
const q = new URLSearchParams(useLocation().search);
|
||||
const outcome = q.get('ck'); // 'success' | 'cancel' | null
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
1) Ask the API for the latest user profile (flags, etc.)
|
||||
– will be fast because JWT is already cached
|
||||
───────────────────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
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 <p className="p-8 text-center">Checking your subscription…</p>;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
3) Success – Stripe completed the checkout flow
|
||||
───────────────────────────────────────────────────────── */
|
||||
if (outcome === 'success') {
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-8 text-center space-y-6">
|
||||
<h1 className="text-2xl font-semibold">🎉 Subscription activated!</h1>
|
||||
<p className="text-gray-600">
|
||||
Premium features have been unlocked on your account.
|
||||
</p>
|
||||
|
||||
<Button asChild className="w-full">
|
||||
<Link to="/premium-onboarding">Set up Premium Features</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" asChild className="w-full">
|
||||
<Link to="/profile">Go to my account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
4) Cancelled – user backed out of Stripe
|
||||
───────────────────────────────────────────────────────── */
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-8 text-center space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Subscription cancelled</h1>
|
||||
<p className="text-gray-600">No changes were made to your account.</p>
|
||||
|
||||
<Button asChild className="w-full">
|
||||
<Link to="/paywall">Back to pricing</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -959,7 +959,6 @@ useEffect(() => {
|
||||
|
||||
// 8) Build financial projection
|
||||
async function buildProjection(milestones) {
|
||||
if (!milestones?.length) return;
|
||||
const allMilestones = milestones || [];
|
||||
try {
|
||||
setScenarioMilestones(allMilestones);
|
||||
|
@ -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 <p className="p-6 text-center text-sm">Loading …</p>;
|
||||
|
||||
if (sub.is_premium || sub.is_pro_premium) {
|
||||
const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium';
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-6 text-center space-y-4">
|
||||
<h2 className="text-xl font-semibold">Your plan: {plan}</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Manage payment method, invoices or cancel anytime.
|
||||
</p>
|
||||
|
||||
<Button onClick={openPortal} className="w-full">
|
||||
Manage subscription
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
|
||||
Back to app
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/* ─── no active sub => show the pricing choices ──────────────── */
|
||||
return (
|
||||
<div className="paywall">
|
||||
<h2>Unlock AptivaAI Premium</h2>
|
||||
<ul>
|
||||
<li>✅ Personalized Career Milestone Planning</li>
|
||||
<li>✅ Comprehensive Financial Projections</li>
|
||||
<li>✅ Resume & Interview Assistance</li>
|
||||
</ul>
|
||||
<div className="max-w-lg mx-auto p-6 space-y-8">
|
||||
<header className="text-center">
|
||||
<h2 className="text-2xl font-semibold">Upgrade to AptivaAI</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Choose the plan that fits your needs – cancel anytime.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Button
|
||||
onClick={handleSubscribe}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Subscribe Now
|
||||
</Button>
|
||||
{/* Premium tier */}
|
||||
<section className="border rounded-lg p-4 space-y-4">
|
||||
<h3 className="text-lg font-medium">Premium</h3>
|
||||
<ul className="text-sm list-disc list-inside space-y-1">
|
||||
<li>Career milestone planning</li>
|
||||
<li>Financial projections & benchmarks</li>
|
||||
<li>2 × resume optimizations / week</li>
|
||||
</ul>
|
||||
|
||||
<Button onClick={() => navigate(-1)}>Cancel / Go Back</Button>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button onClick={() => checkout('premium', 'monthly')}>$4.99 / mo</Button>
|
||||
<Button onClick={() => checkout('premium', 'annual' )}>$49 / yr</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pro tier */}
|
||||
<section className="border rounded-lg p-4 space-y-4">
|
||||
<h3 className="text-lg font-medium">Pro Premium</h3>
|
||||
<ul className="text-sm list-disc list-inside space-y-1">
|
||||
<li>Everything in Premium</li>
|
||||
<li>Priority GPT‑4o usage & higher rate limits</li>
|
||||
<li>5 × resume optimizations / week</li>
|
||||
</ul>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button onClick={() => checkout('pro', 'monthly')}>$7.99 / mo</Button>
|
||||
<Button onClick={() => checkout('pro', 'annual' )}>$79 / yr</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
|
||||
Cancel / Go back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Paywall;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user