content-grade 1.0.18 → 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.
- package/bin/content-grade.js +79 -90
- package/bin/telemetry.js +12 -6
- package/dist-server/server/db.js +3 -1
- package/dist-server/server/index.js +16 -2
- package/dist-server/server/routes/demos.js +52 -25
- package/dist-server/server/routes/stripe.js +20 -2
- package/dist-server/server/services/stripe.js +40 -11
- package/package.json +1 -1
- package/dist/landing.html +0 -1464
- package/dist/privacy.html +0 -189
- package/dist/terms.html +0 -185
package/bin/content-grade.js
CHANGED
|
@@ -48,7 +48,9 @@ function getLicenseKey() {
|
|
|
48
48
|
|
|
49
49
|
function getLicenseTier() {
|
|
50
50
|
const cfg = loadConfig();
|
|
51
|
-
|
|
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
|
-
//
|
|
75
|
-
const TIER_LIMITS = {
|
|
76
|
-
free: 5, // 5/day — try it out
|
|
77
|
-
starter: 10, // 10/day — $0.030/run
|
|
78
|
-
pro: 50, // 50/day — $0.019/run (best value per run)
|
|
79
|
-
team: 200, // 200/day — $0.013/run (bulk)
|
|
80
|
-
};
|
|
81
|
-
|
|
76
|
+
// Upgrade links — Free → Pro → Business → Team
|
|
82
77
|
const UPGRADE_LINKS = {
|
|
83
|
-
free:
|
|
84
|
-
|
|
85
|
-
|
|
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:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
team:
|
|
84
|
+
free: 'Free',
|
|
85
|
+
pro: 'Pro',
|
|
86
|
+
business: 'Business',
|
|
87
|
+
team: 'Team',
|
|
93
88
|
};
|
|
94
89
|
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
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:
|
|
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}
|
|
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} ·
|
|
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}
|
|
1438
|
+
console.log(` ${B}PRICING${R}`);
|
|
1448
1439
|
blank();
|
|
1449
|
-
console.log(`
|
|
1450
|
-
console.log(`
|
|
1451
|
-
console.log(`
|
|
1452
|
-
console.log(`
|
|
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://
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
221
|
-
return { ok: remaining > 0, used, remaining: Math.max(0, remaining), isPro
|
|
226
|
+
const remaining = limit - used;
|
|
227
|
+
return { ok: remaining > 0, used, remaining: Math.max(0, remaining), limit, isPro };
|
|
222
228
|
}
|
|
223
229
|
|
|
224
230
|
/**
|
package/dist-server/server/db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
6
|
+
import { getActiveSubscriptionLive, hasPurchased } from '../services/stripe.js';
|
|
7
7
|
// ── Usage tracking utilities ──────────────────────────────────────
|
|
8
|
-
const FREE_TIER_LIMIT =
|
|
9
|
-
const PRO_TIER_LIMIT =
|
|
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
|
|
16
|
-
return `
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return { allowed: true, remaining:
|
|
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 ?
|
|
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
|
-
?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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:
|
|
109
|
+
plan_tier: planTier,
|
|
92
110
|
status: 'active',
|
|
93
111
|
current_period_end: 0,
|
|
94
112
|
});
|