Copy overhaul + qr designs

This commit is contained in:
2026-07-27 17:54:59 +02:00
parent 033bc7e29d
commit 70d97aa970
144 changed files with 23107 additions and 1699 deletions

View File

@@ -1,8 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { sendActivationNudgeEmail, sendUpgradeNudgeEmail, sendThirtyDayNudgeEmail } from '@/lib/email';
import {
sendActivationNudgeEmail,
sendUpgradeNudgeEmail,
sendThirtyDayNudgeEmail,
sendFirstScanEmail,
} from '@/lib/email';
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
// Protect with a shared secret set CRON_SECRET in Vercel env vars
// Protect with a shared secret - set CRON_SECRET in Vercel env vars
function isAuthorized(request: NextRequest): boolean {
const authHeader = request.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;
@@ -17,14 +23,17 @@ export async function GET(request: NextRequest) {
const now = new Date();
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
let activationSent = 0;
let upgradeSent = 0;
let limitSent = 0;
let firstScanSent = 0;
let thirtyDaySent = 0;
// Day-3: signed up > 3 days ago, never created a QR code, hasn't received this email yet
// ── Day 3: signed up, never created anything ─────────────────────────────
// Unchanged. This one is legitimately time-based: the absence of activity is
// the trigger, and absence only becomes meaningful after some time passes.
const activationCandidates = await db.user.findMany({
where: {
createdAt: { lt: threeDaysAgo },
@@ -50,34 +59,83 @@ export async function GET(request: NextRequest) {
}
}
// Day-7: signed up > 7 days ago, has ≥1 QR code, still FREE, hasn't received this email yet
const upgradeCandidates = await db.user.findMany({
// ── Limit reached: behaviour, not calendar ───────────────────────────────
// This replaces the old day-7 upgrade nudge, which fired at day 7 regardless
// of usage. Someone with a single code was getting a mail about a ceiling
// they had not come near - an upgrade pitch to a person with no pain, sent
// under the founder's own name. Now it only goes to people actually blocked.
const freeUsers = await db.user.findMany({
where: {
createdAt: { lt: sevenDaysAgo },
upgradeNudgeSentAt: null,
plan: 'FREE',
limitReachedNudgeSentAt: null,
},
include: {
_count: { select: { qrCodes: true } },
},
select: { id: true, email: true, name: true },
});
for (const user of upgradeCandidates) {
if (user._count.qrCodes > 0 && user.email) {
try {
await sendUpgradeNudgeEmail(user.email, user.name ?? 'there', user._count.qrCodes);
await db.user.update({
where: { id: user.id },
data: { upgradeNudgeSentAt: now },
});
upgradeSent++;
} catch (err) {
console.error(`Upgrade nudge failed for ${user.email}:`, err);
}
for (const user of freeUsers) {
if (!user.email) continue;
const activeDynamic = await db.qRCode.count({
where: { userId: user.id, type: 'DYNAMIC', status: 'ACTIVE' },
});
if (activeDynamic < DYNAMIC_QR_LIMITS.FREE) continue;
try {
await sendUpgradeNudgeEmail(user.email, user.name ?? 'there', activeDynamic);
await db.user.update({
where: { id: user.id },
data: { limitReachedNudgeSentAt: now },
});
limitSent++;
} catch (err) {
console.error(`Limit nudge failed for ${user.email}:`, err);
}
}
// Day-30: signed up > 30 days ago, has ≥1 QR code, still FREE, hasn't received this email yet
// ── First scan: the only trigger that is not a date ──────────────────────
// Fires the day after the first scan, so the event is still recent enough to
// be an occasion rather than a fact from the archive. The 7-day floor stops
// this from firing for historic users whose first scan was months ago.
const firstScanCandidates = await db.user.findMany({
where: {
firstScanAt: { not: null, lte: oneDayAgo },
firstScanNudgeSentAt: null,
},
select: { id: true, email: true, name: true, firstScanAt: true },
});
for (const user of firstScanCandidates) {
if (!user.email || !user.firstScanAt) continue;
const scan = await db.qRScan.findFirst({
where: { qr: { userId: user.id } },
orderBy: { ts: 'asc' },
select: { ts: true, device: true, country: true, qr: { select: { title: true } } },
});
if (!scan) continue;
try {
await sendFirstScanEmail(user.email, user.name ?? 'there', {
qrTitle: scan.qr?.title ?? 'your QR code',
device: scan.device,
country: scan.country,
ts: scan.ts,
});
await db.user.update({
where: { id: user.id },
data: { firstScanNudgeSentAt: now },
});
firstScanSent++;
} catch (err) {
console.error(`First scan mail failed for ${user.email}:`, err);
}
}
// ── Day 30: built around the user's own numbers ─────────────────────────
// The old version argued from branding and cited a pattern among Pro users
// that was never sourced. This one argues from the scan count the user
// actually produced, which needs no testimonial to be believable.
const thirtyDayCandidates = await db.user.findMany({
where: {
createdAt: { lt: thirtyDaysAgo },
@@ -90,24 +148,41 @@ export async function GET(request: NextRequest) {
});
for (const user of thirtyDayCandidates) {
if (user._count.qrCodes > 0 && user.email) {
try {
await sendThirtyDayNudgeEmail(user.email, user.name ?? 'there', user._count.qrCodes);
await db.user.update({
where: { id: user.id },
data: { thirtyDayNudgeSentAt: now },
});
thirtyDaySent++;
} catch (err) {
console.error(`30-day nudge failed for ${user.email}:`, err);
}
if (user._count.qrCodes === 0 || !user.email) continue;
const scanCount = await db.qRScan.count({
where: {
qr: { userId: user.id },
ts: { gte: thirtyDaysAgo },
},
});
// No scans means the pitch has no evidence behind it. Staying quiet is
// better than sending "your codes were scanned 0 times this month".
if (scanCount === 0) continue;
try {
await sendThirtyDayNudgeEmail(
user.email,
user.name ?? 'there',
user._count.qrCodes,
scanCount
);
await db.user.update({
where: { id: user.id },
data: { thirtyDayNudgeSentAt: now },
});
thirtyDaySent++;
} catch (err) {
console.error(`30-day nudge failed for ${user.email}:`, err);
}
}
return NextResponse.json({
ok: true,
activationNudgesSent: activationSent,
upgradeNudgesSent: upgradeSent,
limitNudgesSent: limitSent,
firstScanEmailsSent: firstScanSent,
thirtyDayNudgesSent: thirtyDaySent,
});
}