Skip to content

Commit 3049e91

Browse files
JasonYeYuheclaude
andcommitted
feat: add referral credits, API tiering, and Pro upsell emails
Growth features: - Referral system: users get unique referral code, earn +5 AI credits per signup, credits consumed before daily tier limits in AI rate limiter - Referral card on account page showing credits, referral count, and link - Email capture form now passes ref parameter for referral tracking - API rate limit middleware: 60/hr anonymous, 1,000/hr free key, 10,000/hr Pro - API key generation/management endpoints (POST/GET/DELETE /me/api-key) - Pro upsell email: auto-sent when free user hits AI limit (max 1/day) - Server-side colors.js synced with expanded 3,066 color set Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 573a994 commit 3049e91

10 files changed

Lines changed: 368 additions & 6 deletions

File tree

server/ai-rate-limit.js

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@ const crypto = require("crypto");
22
const db = require("./db");
33
const { getSessionUser } = require("./auth");
44

5+
// Lazy-load email to avoid circular deps
6+
let sendProUpsellEmail = null;
7+
function getSendProUpsellEmail() {
8+
if (!sendProUpsellEmail) {
9+
try {
10+
sendProUpsellEmail = require("./email").sendProUpsellEmail;
11+
} catch {
12+
sendProUpsellEmail = () => {};
13+
}
14+
}
15+
return sendProUpsellEmail;
16+
}
17+
518
// Limits per tier per day
619
const TIER_LIMITS = {
720
anonymous: 3,
@@ -30,19 +43,30 @@ function incrementUsage(identifier, date) {
3043
).run(identifier, date);
3144
}
3245

46+
function getUserCredits(userId) {
47+
const row = db.prepare("SELECT credits FROM users WHERE id = ?").get(userId);
48+
return row ? row.credits : 0;
49+
}
50+
51+
function consumeCredit(userId) {
52+
db.prepare("UPDATE users SET credits = MAX(credits - 1, 0) WHERE id = ?").run(userId);
53+
}
54+
3355
/**
3456
* Express middleware that enforces AI generation rate limits.
35-
* Attaches req.aiTier and req.aiIdentifier for downstream use.
57+
* Credits are consumed first before falling back to tier limits.
3658
* Returns 429 when limit is exceeded.
3759
*/
3860
function aiRateLimit(req, res, next) {
3961
const user = getSessionUser(req);
4062
let tier = "anonymous";
4163
let identifier;
64+
let userId = null;
4265

4366
if (user) {
4467
tier = user.tier || "free";
4568
identifier = "user:" + user.id;
69+
userId = user.id;
4670
} else {
4771
identifier = ipHash(req);
4872
}
@@ -52,6 +76,35 @@ function aiRateLimit(req, res, next) {
5276
const used = getUsage(identifier, dateKey);
5377

5478
if (used >= limit) {
79+
// Check if user has credits to spend
80+
if (userId) {
81+
const credits = getUserCredits(userId);
82+
if (credits > 0) {
83+
consumeCredit(userId);
84+
incrementUsage(identifier, dateKey);
85+
req.aiTier = tier;
86+
req.aiIdentifier = identifier;
87+
req.aiUsed = used + 1;
88+
req.aiLimit = limit;
89+
req.aiCreditsUsed = true;
90+
return next();
91+
}
92+
}
93+
94+
// Trigger Pro upsell email for free-tier users (max 1/day, fire-and-forget)
95+
if (userId && tier === "free") {
96+
const upsellKey = `upsell:${userId}`;
97+
const alreadySent = getUsage(upsellKey, dateKey);
98+
if (alreadySent === 0) {
99+
incrementUsage(upsellKey, dateKey);
100+
const userRow = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
101+
if (userRow) {
102+
const send = getSendProUpsellEmail();
103+
if (send) send(userRow.email).catch(() => {});
104+
}
105+
}
106+
}
107+
55108
return res.status(429).json({
56109
error: "Daily AI generation limit reached.",
57110
limit: true,

server/api-rate-limit.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const crypto = require("crypto");
2+
const db = require("./db");
3+
4+
// API rate limits per hour
5+
const API_TIER_LIMITS = {
6+
anonymous: 60, // No API key, IP-based
7+
free: 1000, // Free API key
8+
pro: 10000, // Pro API key
9+
};
10+
11+
function ipHash(req) {
12+
const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
13+
return "api_ip:" + crypto.createHash("sha256").update(ip).digest("hex").slice(0, 16);
14+
}
15+
16+
function currentHour() {
17+
const d = new Date();
18+
return `${d.toISOString().slice(0, 13)}`;
19+
}
20+
21+
// Reuse ai_usage table with "api:" prefix for identifiers
22+
function getApiUsage(identifier, hour) {
23+
const row = db.prepare("SELECT count FROM ai_usage WHERE identifier = ? AND date = ?").get(identifier, hour);
24+
return row ? row.count : 0;
25+
}
26+
27+
function incrementApiUsage(identifier, hour) {
28+
db.prepare(
29+
`INSERT INTO ai_usage (identifier, date, count) VALUES (?, ?, 1)
30+
ON CONFLICT(identifier, date) DO UPDATE SET count = count + 1`
31+
).run(identifier, hour);
32+
}
33+
34+
function lookupApiKey(key) {
35+
if (!key) return null;
36+
return db.prepare("SELECT id, tier FROM users WHERE api_key = ?").get(key);
37+
}
38+
39+
/**
40+
* Express middleware for API rate limiting.
41+
* Checks API key from Authorization header or query param.
42+
* Sets X-RateLimit-* headers on response.
43+
*/
44+
function apiRateLimit(req, res, next) {
45+
// Extract API key
46+
const authHeader = req.headers.authorization;
47+
const key = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : req.query.api_key;
48+
49+
let tier = "anonymous";
50+
let identifier;
51+
52+
if (key) {
53+
const user = lookupApiKey(key);
54+
if (user) {
55+
tier = user.tier || "free";
56+
identifier = "api_user:" + user.id;
57+
} else {
58+
return res.status(401).json({ error: "Invalid API key." });
59+
}
60+
} else {
61+
identifier = ipHash(req);
62+
}
63+
64+
const limit = API_TIER_LIMITS[tier] ?? API_TIER_LIMITS.anonymous;
65+
const hour = currentHour();
66+
const used = getApiUsage(identifier, hour);
67+
const remaining = Math.max(0, limit - used);
68+
69+
// Set rate limit headers
70+
res.setHeader("X-RateLimit-Limit", String(limit));
71+
res.setHeader("X-RateLimit-Remaining", String(remaining));
72+
res.setHeader("X-RateLimit-Reset", new Date(new Date().setMinutes(60, 0, 0)).toISOString());
73+
74+
if (used >= limit) {
75+
res.setHeader("Retry-After", "3600");
76+
return res.status(429).json({
77+
error: "API rate limit exceeded.",
78+
limit,
79+
used,
80+
tier,
81+
retryAfter: 3600,
82+
});
83+
}
84+
85+
incrementApiUsage(identifier, hour);
86+
return next();
87+
}
88+
89+
module.exports = { apiRateLimit, API_TIER_LIMITS };

server/colors.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Server-side color dataset — mirrors src/data/colors.ts
3-
* Generates all 2016 colors algorithmically (36 hues × 14 lightness × 4 chroma).
3+
* Generates 3,066 colors algorithmically (36 hues × 14 lightness × 6 chroma + 3 neutral groups × 14 lightness).
44
*/
55

66
const hueCatalog = [
@@ -29,8 +29,15 @@ const lightBands = [
2929
];
3030

3131
const chromaBands = [
32-
{ label: "Muted", saturation: 18 }, { label: "Soft", saturation: 34 },
33-
{ label: "Clear", saturation: 54 }, { label: "Vivid", saturation: 74 },
32+
{ label: "Faint", saturation: 10 }, { label: "Muted", saturation: 18 },
33+
{ label: "Soft", saturation: 34 }, { label: "Clear", saturation: 54 },
34+
{ label: "Vivid", saturation: 74 }, { label: "Pure", saturation: 92 },
35+
];
36+
37+
const neutralCatalog = [
38+
{ root: "Warm Gray", hue: 30, saturation: 6 },
39+
{ root: "Cool Gray", hue: 210, saturation: 6 },
40+
{ root: "True Gray", hue: 0, saturation: 0 },
3441
];
3542

3643
function hslToRgb(hue, saturation, lightness) {
@@ -79,8 +86,8 @@ function createColorId(name) {
7986
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
8087
}
8188

82-
// Generate all 2016 colors
83-
const colors = hueCatalog.flatMap(({ hue, root }) =>
89+
// Generate chromatic colors (36 × 14 × 6 = 3,024)
90+
const chromaticColors = hueCatalog.flatMap(({ hue, root }) =>
8491
lightBands.flatMap(({ label: lightLabel, lightness }) =>
8592
chromaBands.map(({ label: chromaLabel, saturation }) => {
8693
const name = `${root} ${lightLabel} ${chromaLabel}`;
@@ -98,6 +105,25 @@ const colors = hueCatalog.flatMap(({ hue, root }) =>
98105
)
99106
);
100107

108+
// Generate neutral grays (3 × 14 = 42)
109+
const neutralColors = neutralCatalog.flatMap(({ root, hue, saturation }) =>
110+
lightBands.map(({ label, lightness }) => {
111+
const name = `${root} ${label}`;
112+
const rgb = hslToRgb(hue, saturation, lightness);
113+
return {
114+
id: createColorId(name),
115+
name,
116+
hex: rgbToHex(rgb),
117+
hue,
118+
saturation,
119+
lightness,
120+
family: getColorFamily(hue),
121+
};
122+
})
123+
);
124+
125+
const colors = [...chromaticColors, ...neutralColors];
126+
101127
// Collection definitions (color IDs only — resolved at runtime)
102128
const collectionDefs = [
103129
{

server/db.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ ensureColumn("subscribers", "cotd_last_sent TEXT");
107107

108108
ensureColumn("users", "tier TEXT DEFAULT 'free'");
109109
ensureColumn("users", "pro_expires_at TEXT");
110+
ensureColumn("users", "credits INTEGER DEFAULT 0");
111+
ensureColumn("users", "referral_code TEXT");
112+
ensureColumn("users", "api_key TEXT");
113+
114+
ensureColumn("subscribers", "referred_by TEXT");
110115

111116
// AI usage tracking — per user (or IP hash) per day
112117
db.exec(`

server/email.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,43 @@ async function sendCotdEmail(to, color, dateStr) {
943943
return result;
944944
}
945945

946+
async function sendProUpsellEmail(email) {
947+
if (!resend) return;
948+
const result = await resend.emails.send({
949+
from: FROM_EMAIL,
950+
to: email,
951+
subject: "You've hit your daily limit — unlock unlimited AI with Pro",
952+
html: `
953+
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;padding:32px 24px;">
954+
<p style="color:#1a1a2e;font-size:15px;line-height:1.6;">
955+
Hey there,
956+
</p>
957+
<p style="color:#555;font-size:14px;line-height:1.6;">
958+
You've used all your free AI generations for today. That means you're getting real value from ColorArchive — nice!
959+
</p>
960+
<p style="color:#555;font-size:14px;line-height:1.6;">
961+
With <strong>Pro</strong>, you get <strong>unlimited</strong> AI palette generations, exports, WCAG reports, and more — for just $4.99/month.
962+
</p>
963+
<div style="text-align:center;margin:24px 0;">
964+
<a href="https://colorarchive.me/pro" style="display:inline-block;background:#6366f1;color:#fff;padding:12px 28px;border-radius:12px;text-decoration:none;font-weight:600;font-size:14px;">
965+
Upgrade to Pro
966+
</a>
967+
</div>
968+
<p style="color:#999;font-size:12px;line-height:1.5;">
969+
Your free generations reset tomorrow. Or share your referral link to earn bonus AI credits!
970+
</p>
971+
<p style="color:#ccc;font-size:11px;margin-top:24px;">
972+
ColorArchive · hello@colorarchive.me
973+
</p>
974+
</div>
975+
`,
976+
});
977+
if (result.error) {
978+
console.error("Resend error (pro upsell):", JSON.stringify(result.error));
979+
}
980+
return result;
981+
}
982+
946983
module.exports = {
947984
sendFreePackEmail,
948985
sendFollowUp3DayEmail,
@@ -955,4 +992,5 @@ module.exports = {
955992
sendWaitlistConfirmationEmail,
956993
sendNewsletterIssueAlert,
957994
sendCotdEmail,
995+
sendProUpsellEmail,
958996
};

server/routes/me.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const express = require("express");
2+
const crypto = require("crypto");
23
const router = express.Router();
34
const db = require("../db");
45
const {
@@ -148,4 +149,62 @@ router.post("/orders/:orderId/resend", async (req, res) => {
148149
}
149150
});
150151

152+
// --- Referral System ---
153+
154+
function ensureReferralCode(userId) {
155+
const user = db.prepare("SELECT referral_code FROM users WHERE id = ?").get(userId);
156+
if (user.referral_code) return user.referral_code;
157+
const code = crypto.randomBytes(4).toString("hex");
158+
db.prepare("UPDATE users SET referral_code = ? WHERE id = ?").run(code, userId);
159+
return code;
160+
}
161+
162+
router.get("/referral", (req, res) => {
163+
const code = ensureReferralCode(req.user.id);
164+
const user = db.prepare("SELECT credits FROM users WHERE id = ?").get(req.user.id);
165+
166+
// Count referrals
167+
const referrals = db
168+
.prepare("SELECT COUNT(*) as count FROM subscribers WHERE referred_by = ?")
169+
.get(code);
170+
171+
return res.json({
172+
code,
173+
credits: user.credits || 0,
174+
referrals: referrals.count,
175+
link: `https://colorarchive.me/?ref=${code}`,
176+
});
177+
});
178+
179+
router.post("/referral/share", (req, res) => {
180+
// Award credits for sharing (best-effort, called when share intent fires)
181+
const SHARE_CREDITS = 2;
182+
db.prepare("UPDATE users SET credits = credits + ? WHERE id = ?").run(SHARE_CREDITS, req.user.id);
183+
const user = db.prepare("SELECT credits FROM users WHERE id = ?").get(req.user.id);
184+
return res.json({ ok: true, credits: user.credits });
185+
});
186+
187+
// --- API Key Management ---
188+
189+
router.get("/api-key", (req, res) => {
190+
const user = db.prepare("SELECT api_key FROM users WHERE id = ?").get(req.user.id);
191+
return res.json({ apiKey: user.api_key || null });
192+
});
193+
194+
router.post("/api-key", (req, res) => {
195+
const existing = db.prepare("SELECT api_key FROM users WHERE id = ?").get(req.user.id);
196+
if (existing.api_key) {
197+
return res.json({ apiKey: existing.api_key });
198+
}
199+
200+
const key = `ca_${crypto.randomBytes(16).toString("hex")}`;
201+
db.prepare("UPDATE users SET api_key = ? WHERE id = ?").run(key, req.user.id);
202+
return res.json({ apiKey: key });
203+
});
204+
205+
router.delete("/api-key", (req, res) => {
206+
db.prepare("UPDATE users SET api_key = NULL WHERE id = ?").run(req.user.id);
207+
return res.json({ ok: true });
208+
});
209+
151210
module.exports = router;

server/routes/subscribe.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ router.post("/", async (req, res) => {
1616
cotd = false,
1717
landingPath,
1818
referrer,
19+
ref,
1920
utmSource,
2021
utmMedium,
2122
utmCampaign,
@@ -70,6 +71,18 @@ router.post("/", async (req, res) => {
7071
.run(email.trim().toLowerCase());
7172
}
7273

74+
// Referral credit: if ref code provided, credit the referrer +5 AI credits
75+
const refCode = sanitizeString(ref, 20);
76+
if (refCode) {
77+
db.prepare("UPDATE subscribers SET referred_by = ? WHERE email = ?")
78+
.run(refCode, email.trim().toLowerCase());
79+
// Credit the referrer (find user by referral_code)
80+
const referrerUser = db.prepare("SELECT id FROM users WHERE referral_code = ?").get(refCode);
81+
if (referrerUser) {
82+
db.prepare("UPDATE users SET credits = credits + 5 WHERE id = ?").run(referrerUser.id);
83+
}
84+
}
85+
7386
if (source === "waitlist") {
7487
await sendWaitlistConfirmationEmail(email);
7588
} else {

0 commit comments

Comments
 (0)