content-grade 1.0.17 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,7 +48,9 @@ function getLicenseKey() {
48
48
 
49
49
  function getLicenseTier() {
50
50
  const cfg = loadConfig();
51
- return cfg.tier || 'free'; // free | starter | pro | team
51
+ const tier = cfg.tier || 'free';
52
+ // Collapse legacy 'starter' tier to 'pro' — Starter was removed
53
+ return tier === 'starter' ? 'pro' : tier;
52
54
  }
53
55
 
54
56
  function isProUser() { return Boolean(getLicenseKey()); }
@@ -71,45 +73,76 @@ function incrementUsage() {
71
73
  return u.count;
72
74
  }
73
75
 
74
- // Usage tiersdaily limits per plan
75
- const TIER_LIMITS = {
76
- free: 5, // 5/day
77
- starter: 20, // ~600/mo ≈ 50/mo was too low for daily use
78
- pro: 50, // ~1500/mo
79
- team: 100, // hard cap even for top tier — no abuse
80
- };
81
-
76
+ // Upgrade linksFree Pro → Business → Team
82
77
  const UPGRADE_LINKS = {
83
- free: 'https://buy.stripe.com/eVq6oJbjSdyd6jbaow8k805', // Starter $9/mo
84
- starter: 'https://buy.stripe.com/00w9AV87G65L7nf54c8k806', // Pro $29/mo
85
- pro: 'https://buy.stripe.com/4gM28t73C79P0YR7ck8k807', // Team $79/mo
78
+ free: 'https://content-grade.onrender.com/#pricing', // Pro $9/mo
79
+ pro: 'https://content-grade.onrender.com/#pricing', // Business $29/mo
80
+ business: 'https://content-grade.onrender.com/#pricing', // Team $79/mo
86
81
  };
87
82
 
88
83
  const TIER_NAMES = {
89
- free: 'Free (5/day)',
90
- starter: 'Starter — $9/mo (20/day)',
91
- pro: 'Pro — $29/mo (50/day)',
92
- team: 'Team — $79/mo (100/day)',
84
+ free: 'Free',
85
+ pro: 'Pro',
86
+ business: 'Business',
87
+ team: 'Team',
93
88
  };
94
89
 
95
- function getDailyLimit() {
90
+ const TIER_LIMITS = {
91
+ free: 3,
92
+ pro: 20,
93
+ business: 100,
94
+ team: 500,
95
+ };
96
+
97
+ function getUpgradeMessage() {
96
98
  const tier = getLicenseTier();
97
- return TIER_LIMITS[tier] || TIER_LIMITS.free;
99
+ if (tier === 'free') return `Upgrade to Pro (20/day) → ${UPGRADE_LINKS.free}`;
100
+ if (tier === 'pro') return `Upgrade to Business (100/day) → ${UPGRADE_LINKS.pro}`;
101
+ if (tier === 'business') return `Upgrade to Team (500/day) → ${UPGRADE_LINKS.business}`;
102
+ return '';
98
103
  }
99
104
 
100
- function checkDailyLimit() {
101
- const limit = getDailyLimit();
102
- const u = getUsage();
103
- if (u.count >= limit) return { ok: false, count: u.count, limit };
104
- return { ok: true, count: u.count, limit };
105
- }
105
+ // Show usage-aware upgrade CTA after each free run.
106
+ // count = today's usage AFTER this run (1 = first run, 3 = last free run).
107
+ function showFreeTierCTA(count) {
108
+ const limit = TIER_LIMITS.free; // 3
109
+ const remaining = Math.max(0, limit - count);
106
110
 
107
- function getUpgradeMessage() {
108
- const tier = getLicenseTier();
109
- const link = UPGRADE_LINKS[tier];
110
- if (!link) return ''; // team tier — no upgrade available
111
- const nextTier = tier === 'free' ? 'Starter ($9/mo, 20/day)' : tier === 'starter' ? 'Pro ($29/mo, 50/day)' : 'Team ($79/mo, 100/day)';
112
- return `Upgrade to ${nextTier} ${link}`;
111
+ blank();
112
+ hr();
113
+
114
+ if (remaining === 0) {
115
+ // Last free run used maximum urgency
116
+ console.log(` ${RD}${B}Free tier limit reached${R} ${D}(${count}/${limit} runs used today)${R}`);
117
+ blank();
118
+ console.log(` You've seen what ContentGrade can do. Don't stop here.`);
119
+ console.log(` ${B}Pro${R} gives you ${B}20 analyses/day${R} + batch mode for whole directories.`);
120
+ blank();
121
+ console.log(` ${MG}${B}→ Upgrade for $9/mo${R} ${CY}${UPGRADE_LINKS.free}${R}`);
122
+ blank();
123
+ console.log(` ${D}Already have a key? ${CY}content-grade activate${R}`);
124
+ } else if (remaining === 1) {
125
+ // 1 run left — build urgency
126
+ console.log(` ${YL}${B}${count}/${limit} free runs used today${R} ${D}· 1 remaining${R}`);
127
+ blank();
128
+ console.log(` One run left on the free tier. ${B}Pro unlocks 20/day${R} + batch analysis.`);
129
+ console.log(` ${MG}→ Upgrade for $9/mo:${R} ${CY}${UPGRADE_LINKS.free}${R}`);
130
+ blank();
131
+ console.log(` ${D}Or continue: ${CY}content-grade analyze ./another-post.md${R}`);
132
+ } else {
133
+ // 1+ runs left — light, value-focused nudge
134
+ console.log(` ${D}Free tier: ${count}/${limit} runs used today · ${remaining} remaining${R}`);
135
+ blank();
136
+ console.log(` ${B}What to do next:${R}`);
137
+ blank();
138
+ console.log(` ${CY}content-grade headline "Your title here"${R} ${D}# grade a headline in ~5s${R}`);
139
+ console.log(` ${CY}content-grade analyze ./another-post.md${R} ${D}# analyze another file${R}`);
140
+ console.log(` ${CY}content-grade analyze https://yoursite.com/post${R} ${D}# audit any live URL${R}`);
141
+ blank();
142
+ console.log(` ${MG}Unlock batch mode:${R} ${D}analyze a whole directory at once${R}`);
143
+ console.log(` ${D} ${CY}content-grade activate${R} ${D}→ enter your Pro license key ($9/mo)${R}`);
144
+ console.log(` ${D} Get one: ${CY}${UPGRADE_LINKS.free}${R}`);
145
+ }
113
146
  }
114
147
 
115
148
  let _version = '1.0.0';
@@ -377,21 +410,6 @@ async function cmdAnalyze(filePath) {
377
410
  process.exit(1);
378
411
  }
379
412
 
380
- // Free tier daily limit
381
- const limitCheck = checkDailyLimit();
382
- if (!limitCheck.ok) {
383
- blank();
384
- fail(`Daily limit reached (${limitCheck.count}/${limitCheck.limit} runs used today on ${TIER_NAMES[getLicenseTier()]} plan).`);
385
- blank();
386
- console.log(` ${B}Options:${R}`);
387
- console.log(` ${D}· Wait until tomorrow (limit resets at midnight)${R}`);
388
- console.log(` ${D}· Upgrade your plan: ${CY}content-grade activate${R}`);
389
- blank();
390
- console.log(` ${MG}${getUpgradeMessage()}${R}`);
391
- blank();
392
- process.exit(1);
393
- }
394
-
395
413
  if (!_jsonMode && !_quietMode) {
396
414
  banner();
397
415
  console.log(` ${B}Analyzing:${R} ${CY}${basename(absPath)}${R}`);
@@ -536,7 +554,7 @@ async function cmdAnalyze(filePath) {
536
554
  }
537
555
 
538
556
  // Track usage
539
- incrementUsage();
557
+ const usageCount = incrementUsage();
540
558
 
541
559
  // Save to file if --save flag is set
542
560
  if (_saveMode && result) {
@@ -551,25 +569,13 @@ async function cmdAnalyze(filePath) {
551
569
  }
552
570
  }
553
571
 
554
- // Next step — graduated CTA after user has seen value
555
- blank();
556
- hr();
572
+ // Next step — usage-aware CTA after user has seen value
557
573
  if (isProUser()) {
574
+ blank();
575
+ hr();
558
576
  console.log(` ${D}Pro active · Next: ${CY}content-grade batch ./posts/${R}${D} to analyze a whole directory${R}`);
559
577
  } else {
560
- const usage = getUsage();
561
- const remaining = Math.max(0, FREE_DAILY_LIMIT - usage.count);
562
- console.log(` ${B}What to do next:${R}`);
563
- blank();
564
- console.log(` ${CY}content-grade headline "Your title here"${R} ${D}# grade a headline in ~5s${R}`);
565
- console.log(` ${CY}content-grade analyze ./another-post.md${R} ${D}# analyze another file${R}`);
566
- console.log(` ${CY}content-grade analyze https://yoursite.com/post${R} ${D}# audit any live URL${R}`);
567
- blank();
568
- if (remaining <= 10) {
569
- console.log(` ${YL}${remaining} runs left today (${TIER_NAMES[getLicenseTier()]}).${R} ${MG}${getUpgradeMessage()}${R}`);
570
- } else {
571
- console.log(` ${D}${remaining} runs left today. ${getUpgradeMessage()}${R}`);
572
- }
578
+ showFreeTierCTA(usageCount);
573
579
  }
574
580
 
575
581
  // CI exit code — shown after full output so user sees the score before exit
@@ -642,21 +648,6 @@ async function cmdHeadline(text) {
642
648
  process.exit(1);
643
649
  }
644
650
 
645
- // Free tier daily limit
646
- const limitCheck = checkDailyLimit();
647
- if (!limitCheck.ok) {
648
- blank();
649
- fail(`Daily limit reached (${limitCheck.count}/${limitCheck.limit} runs used today on ${TIER_NAMES[getLicenseTier()]} plan).`);
650
- blank();
651
- console.log(` ${B}Options:${R}`);
652
- console.log(` ${D}· Wait until tomorrow (limit resets at midnight)${R}`);
653
- console.log(` ${D}· Upgrade your plan: ${CY}content-grade activate${R}`);
654
- blank();
655
- console.log(` ${MG}${getUpgradeMessage()}${R}`);
656
- blank();
657
- process.exit(1);
658
- }
659
-
660
651
  if (!_jsonMode && !_quietMode) {
661
652
  banner();
662
653
  console.log(` ${B}Grading headline:${R}`);
@@ -742,8 +733,8 @@ async function cmdHeadline(text) {
742
733
  console.log(` ${D}Compare two headlines: ${CY}content-grade start${R} → HeadlineGrader compare${R}`);
743
734
  blank();
744
735
  if (!isProUser()) {
745
- console.log(` ${MG}${getUpgradeMessage()}${R}`);
746
- blank();
736
+ const usageCount = incrementUsage();
737
+ showFreeTierCTA(usageCount);
747
738
  }
748
739
  }
749
740
 
@@ -823,7 +814,7 @@ async function cmdInit() {
823
814
  console.log(` ${CY}content-grade start${R}`);
824
815
  blank();
825
816
  }
826
- console.log(` ${D}Pro tier: 100 analyses/day + batch mode — ${CY}content-grade.onrender.com${R}`);
817
+ console.log(` ${D}Pro tier: 20 analyses/day + batch mode — ${CY}content-grade.onrender.com${R}`);
827
818
  blank();
828
819
  }
829
820
 
@@ -843,14 +834,14 @@ async function cmdActivate() {
843
834
  blank();
844
835
  console.log(` ${B}Pro features:${R}`);
845
836
  console.log(` ${D} content-grade batch ./posts/ ${R}${D}# analyze all files in a directory${R}`);
846
- console.log(` ${D} 100 checks/day (vs 50 free)${R}`);
837
+ console.log(` ${D} 20 analyses/day (vs 3 free)${R}`);
847
838
  blank();
848
839
  return;
849
840
  }
850
841
 
851
842
  console.log(` ${D}Pro tier unlocks:${R}`);
852
843
  console.log(` ${D} · ${CY}content-grade batch <dir>${R}${D} — analyze entire directories${R}`);
853
- console.log(` ${D} · 100 checks/day (vs 50 free)${R}`);
844
+ console.log(` ${D} · 20 analyses/day (vs 3 free)${R}`);
854
845
  blank();
855
846
  console.log(` ${D}Get a license: ${CY}content-grade.onrender.com${R}${D} → Pricing${R}`);
856
847
  blank();
@@ -1444,14 +1435,12 @@ function cmdHelp() {
1444
1435
  console.log(` · Node.js 18+`);
1445
1436
  blank();
1446
1437
 
1447
- console.log(` ${B}PRO TIER${R} ${MG}$9/mo${R}`);
1438
+ console.log(` ${B}PRICING${R}`);
1448
1439
  blank();
1449
- console.log(` · 100 analyses/day (vs 50 free)`);
1450
- console.log(` · Competitor headline A/B comparison`);
1451
- console.log(` · Landing page URL audit`);
1452
- console.log(` · Ad copy scoring (Google, Meta, LinkedIn)`);
1453
- console.log(` · Email subject line optimizer`);
1454
- console.log(` · Audience archetype decoder`);
1440
+ console.log(` Free 3/day $0`);
1441
+ console.log(` Pro 20/day $9/mo`);
1442
+ console.log(` Business 100/day $29/mo`);
1443
+ console.log(` Team 500/day $79/mo`);
1455
1444
  blank();
1456
1445
  console.log(` Purchase at: ${CY}content-grade.onrender.com${R}`);
1457
1446
  console.log(` Then unlock: ${CY}content-grade activate <your-license-key>${R}`);
@@ -1922,7 +1911,7 @@ function analysisToMarkdown(result, sourceName, analyzedAt) {
1922
1911
  }
1923
1912
 
1924
1913
  lines.push(`---`);
1925
- lines.push(`Generated by [ContentGrade](https://buy.stripe.com/5kQeVfew48dT7nf2W48k801) v${_version}`);
1914
+ lines.push(`Generated by [ContentGrade](https://content-grade.onrender.com) v${_version}`);
1926
1915
  return lines.join('\n');
1927
1916
  }
1928
1917
 
package/bin/telemetry.js CHANGED
@@ -204,21 +204,27 @@ export function telemetryStatus() {
204
204
 
205
205
  // ── Rate limiting ─────────────────────────────────────────────────────────────
206
206
 
207
- const FREE_DAILY_LIMIT = 50;
207
+ const FREE_DAILY_LIMIT = 3;
208
+ const PRO_DAILY_LIMIT = 20;
209
+ const BUSINESS_DAILY_LIMIT = 100;
210
+ const TEAM_DAILY_LIMIT = 500;
208
211
 
209
212
  /**
210
- * Check whether the user is within the free daily limit for a given command type.
213
+ * Check whether the user is within their daily limit for a given command type.
211
214
  * Type: 'analyze' | 'headline'
212
- * Returns { ok: boolean, used: number, remaining: number, isPro: boolean }
215
+ * Returns { ok: boolean, used: number, remaining: number, limit: number, isPro: boolean }
213
216
  */
214
217
  export function checkDailyLimit(type = 'analyze') {
215
218
  const cfg = loadConfig();
216
- if (cfg?.licenseKey) return { ok: true, used: 0, remaining: Infinity, isPro: true };
219
+ const tier = cfg?.tier || 'free';
220
+ const LIMITS = { free: FREE_DAILY_LIMIT, pro: PRO_DAILY_LIMIT, business: BUSINESS_DAILY_LIMIT, team: TEAM_DAILY_LIMIT };
221
+ const limit = cfg?.licenseKey ? (LIMITS[tier] || PRO_DAILY_LIMIT) : FREE_DAILY_LIMIT;
222
+ const isPro = !!cfg?.licenseKey;
217
223
 
218
224
  const today = new Date().toISOString().slice(0, 10);
219
225
  const used = (cfg?.dailyUsage?.date === today ? cfg.dailyUsage[type] ?? 0 : 0);
220
- const remaining = FREE_DAILY_LIMIT - used;
221
- return { ok: remaining > 0, used, remaining: Math.max(0, remaining), isPro: false };
226
+ const remaining = limit - used;
227
+ return { ok: remaining > 0, used, remaining: Math.max(0, remaining), limit, isPro };
222
228
  }
223
229
 
224
230
  /**
@@ -3,7 +3,9 @@ import { mkdirSync } from 'fs';
3
3
  import { resolve, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
- const DB_PATH = process.env.CONTENTGRADE_DB_PATH ?? resolve(__dirname, '../data/contentgrade.db');
6
+ // On Render, use /tmp for writable SQLite (ephemeral but works)
7
+ const DB_PATH = process.env.CONTENTGRADE_DB_PATH ??
8
+ (process.env.RENDER ? '/tmp/contentgrade.db' : resolve(__dirname, '../data/contentgrade.db'));
7
9
  let _db = null;
8
10
  export function getDb() {
9
11
  if (!_db) {
@@ -10,8 +10,15 @@ import { registerStripeRoutes } from './routes/stripe.js';
10
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  const PORT = parseInt(process.env.PORT || '4000', 10);
12
12
  const isProd = process.env.NODE_ENV === 'production';
13
+ console.log(`[Boot] NODE_ENV=${process.env.NODE_ENV}, isProd=${isProd}, __dirname=${__dirname}, cwd=${process.cwd()}`);
13
14
  // Init DB on startup
14
- getDb();
15
+ try {
16
+ getDb();
17
+ console.log('[DB] SQLite initialized');
18
+ }
19
+ catch (err) {
20
+ console.error('[DB] SQLite init failed:', err);
21
+ }
15
22
  const app = Fastify({ logger: { level: 'warn' } });
16
23
  // Raw body for Stripe webhook signature verification
17
24
  app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
@@ -37,7 +44,14 @@ app.get('/api/health', async () => ({
37
44
  }));
38
45
  // In production, serve the Vite build
39
46
  if (isProd) {
40
- const distPath = resolve(__dirname, '../../dist');
47
+ // Try multiple paths — __dirname varies between local and Render
48
+ const candidates = [
49
+ resolve(__dirname, '..', '..', 'dist'), // dist-server/server/ → dist/
50
+ resolve(process.cwd(), 'dist'), // cwd/dist/
51
+ resolve(__dirname, '..', 'dist'), // one level up
52
+ ];
53
+ const distPath = candidates.find(p => existsSync(p)) ?? candidates[0];
54
+ console.log(`[Static] dist at: ${distPath} (exists: ${existsSync(distPath)}, tried: ${candidates.join(', ')})`);
41
55
  if (existsSync(distPath)) {
42
56
  await app.register(staticPlugin, {
43
57
  root: distPath,
@@ -3,17 +3,25 @@ import * as https from 'https';
3
3
  import * as http from 'http';
4
4
  import { getDb } from '../db.js';
5
5
  import { askClaude } from '../claude.js';
6
- import { hasActiveSubscriptionLive, hasPurchased } from '../services/stripe.js';
6
+ import { getActiveSubscriptionLive, hasPurchased } from '../services/stripe.js';
7
7
  // ── Usage tracking utilities ──────────────────────────────────────
8
- const FREE_TIER_LIMIT = 50;
9
- const PRO_TIER_LIMIT = 100;
8
+ const FREE_TIER_LIMIT = 10;
9
+ const PRO_TIER_LIMIT = 20;
10
+ const BUSINESS_TIER_LIMIT = 100;
11
+ const TEAM_TIER_LIMIT = 500;
10
12
  const HEADLINE_GRADER_ENDPOINT = 'headline-grader';
11
13
  const UPGRADE_URL = 'https://content-grade.github.io/Content-Grade/';
14
+ const TIER_LIMITS = {
15
+ free: FREE_TIER_LIMIT,
16
+ pro: PRO_TIER_LIMIT,
17
+ business: BUSINESS_TIER_LIMIT,
18
+ team: TEAM_TIER_LIMIT,
19
+ };
12
20
  function freeGateMsg(what) {
13
21
  return `Free daily limit reached (${FREE_TIER_LIMIT}/day). ${what} — upgrade to Pro at ${UPGRADE_URL}`;
14
22
  }
15
- function proGateMsg() {
16
- return `Pro daily limit reached (${PRO_TIER_LIMIT}/day). Resets at midnight UTC.`;
23
+ function paidGateMsg(limit) {
24
+ return `Daily limit reached (${limit}/day). Upgrade for more at content-grade.onrender.com/#pricing. Resets at midnight UTC.`;
17
25
  }
18
26
  function hashIp(ip) {
19
27
  return createHash('sha256').update(ip).digest('hex');
@@ -51,15 +59,22 @@ function recordFunnelEvent(event, ipHash, tool, email) {
51
59
  }
52
60
  async function checkRateLimit(ipHash, endpoint, email, productKey) {
53
61
  if (email) {
54
- const isPro = productKey
55
- ? hasPurchased(email, productKey)
56
- : await hasActiveSubscriptionLive(email);
57
- if (isPro) {
62
+ // One-time purchases (e.g. AudienceDecoder) get Pro-level limits
63
+ if (productKey && hasPurchased(email, productKey)) {
58
64
  const count = getUsageCount(ipHash, endpoint);
59
- if (count >= PRO_TIER_LIMIT) {
60
- return { allowed: false, remaining: 0, limit: PRO_TIER_LIMIT, isPro: true };
61
- }
62
- return { allowed: true, remaining: PRO_TIER_LIMIT - count, limit: PRO_TIER_LIMIT, isPro: true };
65
+ const limit = PRO_TIER_LIMIT;
66
+ if (count >= limit)
67
+ return { allowed: false, remaining: 0, limit, isPro: true };
68
+ return { allowed: true, remaining: limit - count, limit, isPro: true };
69
+ }
70
+ // Subscription users — resolve their actual tier
71
+ const sub = await getActiveSubscriptionLive(email);
72
+ if (sub.isPro) {
73
+ const limit = TIER_LIMITS[sub.tier] || PRO_TIER_LIMIT;
74
+ const count = getUsageCount(ipHash, endpoint);
75
+ if (count >= limit)
76
+ return { allowed: false, remaining: 0, limit, isPro: true };
77
+ return { allowed: true, remaining: limit - count, limit, isPro: true };
63
78
  }
64
79
  }
65
80
  const count = getUsageCount(ipHash, endpoint);
@@ -117,12 +132,13 @@ export function registerDemoRoutes(app) {
117
132
  const email = (body?.email ?? '').trim().toLowerCase() || undefined;
118
133
  const rate = await checkRateLimit(ipHash, HEADLINE_GRADER_ENDPOINT, email);
119
134
  if (!rate.allowed) {
135
+ reply.status(429);
120
136
  return {
121
137
  gated: true,
122
138
  isPro: rate.isPro,
123
139
  remaining: 0,
124
140
  limit: rate.limit,
125
- message: rate.isPro ? proGateMsg() : freeGateMsg('Get 100 analyses/day with Pro'),
141
+ message: rate.isPro ? paidGateMsg(rate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
126
142
  };
127
143
  }
128
144
  const wordCountByContext = {
@@ -247,13 +263,14 @@ Respond ONLY with valid JSON matching this exact schema — no markdown, no extr
247
263
  const hgcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
248
264
  const hgcRate = await checkRateLimit(hgcIpHash, HEADLINE_GRADER_ENDPOINT, hgcEmail);
249
265
  if (!hgcRate.allowed) {
266
+ reply.status(429);
250
267
  return {
251
268
  gated: true,
252
269
  isPro: hgcRate.isPro,
253
270
  remaining: 0,
254
271
  limit: hgcRate.limit,
255
272
  message: hgcRate.isPro
256
- ? proGateMsg() : freeGateMsg('Get 100 comparisons/day with Pro'),
273
+ ? paidGateMsg(rate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
257
274
  };
258
275
  }
259
276
  const scoringSystemPrompt = `You are a world-class direct response copywriting analyst. You evaluate headlines using four proven conversion frameworks.
@@ -395,12 +412,13 @@ Write the verdict and suggested hybrid.`;
395
412
  const _prEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
396
413
  const _prRate = await checkRateLimit(_prIpHash, 'page-roast', _prEmail);
397
414
  if (!_prRate.allowed) {
415
+ reply.status(429);
398
416
  return {
399
417
  gated: true,
400
418
  isPro: _prRate.isPro,
401
419
  remaining: 0,
402
420
  limit: _prRate.limit,
403
- message: _prRate.isPro ? proGateMsg() : freeGateMsg('Get 100 analyses/day with Pro'),
421
+ message: _prRate.isPro ? paidGateMsg(_prRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
404
422
  };
405
423
  }
406
424
  let pageText;
@@ -537,12 +555,13 @@ Respond ONLY with valid JSON matching this exact schema — no markdown, no extr
537
555
  const prcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
538
556
  const prcRate = await checkRateLimit(prcIpHash, 'page-roast', prcEmail);
539
557
  if (!prcRate.allowed) {
558
+ reply.status(429);
540
559
  return {
541
560
  gated: true,
542
561
  isPro: prcRate.isPro,
543
562
  remaining: 0,
544
563
  limit: prcRate.limit,
545
- message: prcRate.isPro ? proGateMsg() : freeGateMsg('Get 100 roasts/day with Pro'),
564
+ message: prcRate.isPro ? paidGateMsg(prcRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
546
565
  };
547
566
  }
548
567
  function parseUrl(raw) {
@@ -750,12 +769,13 @@ Write the verdict and analysis.`;
750
769
  const _asEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
751
770
  const _asRate = await checkRateLimit(_asIpHash, 'ad-scorer', _asEmail);
752
771
  if (!_asRate.allowed) {
772
+ reply.status(429);
753
773
  return {
754
774
  gated: true,
755
775
  isPro: _asRate.isPro,
756
776
  remaining: 0,
757
777
  limit: _asRate.limit,
758
- message: _asRate.isPro ? proGateMsg() : freeGateMsg('Get 100 analyses/day with Pro'),
778
+ message: _asRate.isPro ? paidGateMsg(_asRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
759
779
  };
760
780
  }
761
781
  const systemPrompt = `You are a world-class performance ad copywriting analyst. You evaluate ad copy for paid platforms (Facebook, Google, LinkedIn) using proven direct response frameworks.
@@ -849,12 +869,13 @@ Respond ONLY with valid JSON matching this exact schema — no markdown, no extr
849
869
  const ascEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
850
870
  const ascRate = await checkRateLimit(ascIpHash, 'ad-scorer', ascEmail);
851
871
  if (!ascRate.allowed) {
872
+ reply.status(429);
852
873
  return {
853
874
  gated: true,
854
875
  isPro: ascRate.isPro,
855
876
  remaining: 0,
856
877
  limit: ascRate.limit,
857
- message: ascRate.isPro ? proGateMsg() : freeGateMsg('Get 100 scores/day with Pro'),
878
+ message: ascRate.isPro ? paidGateMsg(ascRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
858
879
  };
859
880
  }
860
881
  const scoringSystemPrompt = `You are a world-class performance ad copywriting analyst. You evaluate ad copy for paid platforms (Facebook, Google, LinkedIn) using proven direct response frameworks.
@@ -995,12 +1016,13 @@ Write the verdict, suggested hybrid, and strategic analysis.`;
995
1016
  const _tgEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
996
1017
  const _tgRate = await checkRateLimit(_tgIpHash, 'thread-grader', _tgEmail);
997
1018
  if (!_tgRate.allowed) {
1019
+ reply.status(429);
998
1020
  return {
999
1021
  gated: true,
1000
1022
  isPro: _tgRate.isPro,
1001
1023
  remaining: 0,
1002
1024
  limit: _tgRate.limit,
1003
- message: _tgRate.isPro ? proGateMsg() : freeGateMsg('Get 100 analyses/day with Pro'),
1025
+ message: _tgRate.isPro ? paidGateMsg(_tgRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
1004
1026
  };
1005
1027
  }
1006
1028
  const systemPrompt = `You are a viral content analyst specializing in X/Twitter threads. Score this thread on 4 pillars:
@@ -1094,12 +1116,13 @@ Respond ONLY with valid JSON matching this exact schema — no markdown, no extr
1094
1116
  const tgcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
1095
1117
  const tgcRate = await checkRateLimit(tgcIpHash, 'thread-grader', tgcEmail);
1096
1118
  if (!tgcRate.allowed) {
1119
+ reply.status(429);
1097
1120
  return {
1098
1121
  gated: true,
1099
1122
  isPro: tgcRate.isPro,
1100
1123
  remaining: 0,
1101
1124
  limit: tgcRate.limit,
1102
- message: tgcRate.isPro ? proGateMsg() : freeGateMsg('Get 100 grades/day with Pro'),
1125
+ message: tgcRate.isPro ? paidGateMsg(tgcRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
1103
1126
  };
1104
1127
  }
1105
1128
  const scoringSystemPrompt = `You are a viral content analyst specializing in X/Twitter threads. Score this thread on 4 pillars:
@@ -1233,12 +1256,13 @@ Write the verdict, suggested hybrid hook, and strategic analysis.`;
1233
1256
  const _efEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
1234
1257
  const _efRate = await checkRateLimit(_efIpHash, 'email-forge', _efEmail);
1235
1258
  if (!_efRate.allowed) {
1259
+ reply.status(429);
1236
1260
  return {
1237
1261
  gated: true,
1238
1262
  isPro: _efRate.isPro,
1239
1263
  remaining: 0,
1240
1264
  limit: _efRate.limit,
1241
- message: _efRate.isPro ? proGateMsg() : freeGateMsg('Get 100 sequences/day with Pro'),
1265
+ message: _efRate.isPro ? paidGateMsg(_efRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
1242
1266
  };
1243
1267
  }
1244
1268
  const GOAL_LABELS = {
@@ -1343,12 +1367,13 @@ Make each email feel distinct — different frameworks, different emotional leve
1343
1367
  const efcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
1344
1368
  const efcRate = await checkRateLimit(efcIpHash, 'email-forge', efcEmail);
1345
1369
  if (!efcRate.allowed) {
1370
+ reply.status(429);
1346
1371
  return {
1347
1372
  gated: true,
1348
1373
  isPro: efcRate.isPro,
1349
1374
  remaining: 0,
1350
1375
  limit: efcRate.limit,
1351
- message: efcRate.isPro ? proGateMsg() : freeGateMsg('Get unlimited sequences/day with Pro'),
1376
+ message: efcRate.isPro ? paidGateMsg(efcRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
1352
1377
  };
1353
1378
  }
1354
1379
  const systemPrompt = `You are an elite email copywriter who has studied AIDA, PAS, Hormozi, Cialdini, and narrative frameworks deeply. You write high-converting email sequences for real businesses.
@@ -1505,12 +1530,13 @@ Overall winner: Sequence ${winner} by ${margin} points.`;
1505
1530
  const _adEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
1506
1531
  const _adRate = await checkRateLimit(_adIpHash, 'audience-decoder', _adEmail, 'audiencedecoder_report');
1507
1532
  if (!_adRate.allowed) {
1533
+ reply.status(429);
1508
1534
  return {
1509
1535
  gated: true,
1510
1536
  isPro: _adRate.isPro,
1511
1537
  remaining: 0,
1512
1538
  limit: _adRate.limit,
1513
- message: _adRate.isPro ? proGateMsg() : freeGateMsg('Purchase the full report for 100 analyses/day'),
1539
+ message: _adRate.isPro ? paidGateMsg(_adRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
1514
1540
  };
1515
1541
  }
1516
1542
  const systemPrompt = `You are an audience intelligence analyst. Analyze the creator's content portfolio and return a JSON object.
@@ -1602,12 +1628,13 @@ Respond ONLY with valid JSON matching this exact schema — no markdown, no extr
1602
1628
  const adcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
1603
1629
  const adcRate = await checkRateLimit(adcIpHash, 'audience-decoder', adcEmail, 'audiencedecoder_report');
1604
1630
  if (!adcRate.allowed) {
1631
+ reply.status(429);
1605
1632
  return {
1606
1633
  gated: true,
1607
1634
  isPro: adcRate.isPro,
1608
1635
  remaining: 0,
1609
1636
  limit: adcRate.limit,
1610
- message: adcRate.isPro ? proGateMsg() : freeGateMsg('Purchase the full report for unlimited analyses/day'),
1637
+ message: adcRate.isPro ? paidGateMsg(adcRate.limit) : freeGateMsg('Upgrade for more at content-grade.onrender.com/#pricing'),
1611
1638
  };
1612
1639
  }
1613
1640
  const analyzeSystemPrompt = `You are an audience intelligence analyst. Analyze the creator's content portfolio and return a JSON object.
@@ -1,4 +1,4 @@
1
- import { getStripe, isStripeConfigured, isAudienceDecoderConfigured, hasActiveSubscription, hasPurchased, getCustomerStripeId, upsertCustomer, saveSubscription, updateSubscriptionStatus, updateSubscriptionPeriod, saveOneTimePurchase, } from '../services/stripe.js';
1
+ import { getStripe, isStripeConfigured, isAudienceDecoderConfigured, hasActiveSubscription, hasPurchased, getCustomerStripeId, upsertCustomer, saveSubscription, updateSubscriptionStatus, updateSubscriptionPeriod, saveOneTimePurchase, priceToPlanTier, } from '../services/stripe.js';
2
2
  import { upsertLicenseKey, getLicenseKeysForEmail, validateLicenseKey } from '../services/license.js';
3
3
  export function registerStripeRoutes(app) {
4
4
  app.post('/api/stripe/create-checkout-session', async (req, reply) => {
@@ -20,6 +20,14 @@ export function registerStripeRoutes(app) {
20
20
  priceId = process.env.STRIPE_PRICE_CONTENTGRADE_PRO;
21
21
  mode = 'subscription';
22
22
  }
23
+ else if (priceType === 'contentgrade_business') {
24
+ priceId = process.env.STRIPE_PRICE_CONTENTGRADE_BUSINESS;
25
+ mode = 'subscription';
26
+ }
27
+ else if (priceType === 'contentgrade_team') {
28
+ priceId = process.env.STRIPE_PRICE_CONTENTGRADE_TEAM;
29
+ mode = 'subscription';
30
+ }
23
31
  else {
24
32
  priceId = process.env.STRIPE_PRICE_AUDIENCEDECODER;
25
33
  mode = 'payment';
@@ -84,11 +92,21 @@ export function registerStripeRoutes(app) {
84
92
  upsertCustomer(email, stripeCustomerId);
85
93
  }
86
94
  if (data.mode === 'subscription' && email && stripeCustomerId) {
95
+ // Resolve plan tier from the Stripe price ID on the checkout line items
96
+ let planTier = 'pro';
97
+ try {
98
+ const stripe = getStripe();
99
+ const lineItems = await stripe.checkout.sessions.listLineItems(data.id, { limit: 1 });
100
+ const priceId = lineItems.data[0]?.price?.id;
101
+ if (priceId)
102
+ planTier = priceToPlanTier(priceId);
103
+ }
104
+ catch { /* default to pro */ }
87
105
  saveSubscription({
88
106
  email,
89
107
  stripe_customer_id: stripeCustomerId,
90
108
  stripe_subscription_id: data.subscription,
91
- plan_tier: 'pro',
109
+ plan_tier: planTier,
92
110
  status: 'active',
93
111
  current_period_end: 0,
94
112
  });