Copy overhaul + qr designs
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user