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

@@ -97,14 +97,14 @@ export async function POST(request: NextRequest) {
triggerLifecycleScoring(user.id, 'signup');
// Send welcome email (fire-and-forget never block signup)
// Send welcome email (fire-and-forget - never block signup)
try {
await sendWelcomeEmail(user.email, user.name ?? 'there');
} catch (emailError) {
console.error('Welcome email failed:', emailError);
}
// Meta Conversions API CompleteRegistration event
// Meta Conversions API - CompleteRegistration event
sendConversionEvent({
eventName: 'CompleteRegistration',
userData: {

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,
});
}

View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { z } from 'zod';
/**
* Saved QR design presets.
*
* Business-only. The value here is repeatability, not novelty: an agency running
* several clients needs client A to look identical across every code, including
* the 500 that came out of one spreadsheet upload.
*/
const MAX_PRESETS = 50;
const presetSchema = z.object({
name: z.string().min(1, 'Name is required').max(60),
style: z.record(z.any()),
});
function isAllowed(plan: string | undefined): boolean {
return plan === 'BUSINESS' || plan === 'ENTERPRISE';
}
export async function GET() {
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const presets = await db.qRDesignPreset.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
});
return NextResponse.json(presets);
}
export async function POST(request: NextRequest) {
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: { plan: true },
});
if (!isAllowed(user?.plan)) {
return NextResponse.json(
{
error: 'Upgrade required',
message: 'Saved design presets are part of the Business plan.',
plan: user?.plan ?? 'FREE',
},
{ status: 403 }
);
}
let data;
try {
data = presetSchema.parse(await request.json());
} catch (err) {
if (err instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: err.errors },
{ status: 400 }
);
}
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
}
const count = await db.qRDesignPreset.count({ where: { userId } });
const existing = await db.qRDesignPreset.findFirst({
where: { userId, name: data.name },
select: { id: true },
});
if (!existing && count >= MAX_PRESETS) {
return NextResponse.json(
{
error: 'Preset limit reached',
message: `You can keep up to ${MAX_PRESETS} presets. Delete one to save another.`,
},
{ status: 403 }
);
}
// Same name overwrites rather than creating a near-duplicate. Someone saving
// "Client A" twice means "update it", not "keep both".
const preset = await db.qRDesignPreset.upsert({
where: { userId_name: { userId, name: data.name } },
create: { userId, name: data.name, style: data.style },
update: { style: data.style },
});
return NextResponse.json(preset, { status: existing ? 200 : 201 });
}
export async function DELETE(request: NextRequest) {
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const id = new URL(request.url).searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const deleted = await db.qRDesignPreset.deleteMany({ where: { id, userId } });
if (deleted.count === 0) {
return NextResponse.json({ error: 'Preset not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}

View File

@@ -4,6 +4,7 @@ import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
const updateQRSchema = z.object({
title: z.string().min(1).optional(),
@@ -103,6 +104,39 @@ export async function PATCH(
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Reactivating a paused code consumes a slot again. Without this check,
// pause -> create a new one -> unpause would quietly put the user over
// their plan limit.
if (
data.status === 'ACTIVE' &&
existing.status === 'PAUSED' &&
existing.type === 'DYNAMIC'
) {
const user = await db.user.findUnique({
where: { id: userId },
select: { plan: true },
});
const limit =
DYNAMIC_QR_LIMITS[(user?.plan ?? 'FREE') as keyof typeof DYNAMIC_QR_LIMITS] ??
DYNAMIC_QR_LIMITS.FREE;
const activeCount = await db.qRCode.count({
where: { userId, type: 'DYNAMIC', status: 'ACTIVE' },
});
if (activeCount >= limit) {
return NextResponse.json(
{
error: 'Limit reached',
message: `You have ${activeCount} of ${limit} dynamic QR codes active. Pause another one first, or upgrade to reactivate this code.`,
currentCount: activeCount,
limit,
plan: user?.plan ?? 'FREE',
},
{ status: 403 }
);
}
}
// Static QR codes cannot be edited
if (existing.type === 'STATIC' && data.content) {
return NextResponse.json(
@@ -119,6 +153,7 @@ export async function PATCH(
...(data.content && { content: data.content }),
...(data.tags && { tags: data.tags }),
...(data.style && { style: data.style }),
...(data.status && { status: data.status }),
},
});

View File

@@ -16,6 +16,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const qrCodes = await db.qRCode.findMany({
where: { userId },
include: {
@@ -30,11 +32,26 @@ export async function GET(request: NextRequest) {
orderBy: { createdAt: 'desc' },
});
// Recent activity per code. Used by the upgrade modal so someone deciding
// which code to pause can see which one is actually dead, rather than
// guessing from a lifetime total that says nothing about right now.
const recentScans = await db.qRScan.groupBy({
by: ['qrId'],
where: {
ts: { gte: thirtyDaysAgo },
qr: { userId },
},
_count: { _all: true },
});
const recentByQr = new Map(recentScans.map(r => [r.qrId, r._count._all]));
// Transform the data
const transformed = qrCodes.map(qr => ({
...qr,
scans: qr._count.scans,
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
scans30d: recentByQr.get(qr.id) ?? 0,
_count: undefined,
}));
@@ -113,10 +130,13 @@ export async function POST(request: NextRequest) {
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
if (!isStatic) {
// Count existing dynamic QR codes
// Only ACTIVE codes consume a slot. Pausing a code frees one, which is what
// the pricing page has always promised ("3 active dynamic QR codes").
const dynamicQRCount = await db.qRCode.count({
where: {
userId,
type: 'DYNAMIC',
status: 'ACTIVE',
},
});

View File

@@ -35,7 +35,16 @@ export async function POST(request: NextRequest) {
}
// Get plan and billing interval from request
const { plan, billingInterval = 'month' } = await request.json();
const { plan, billingInterval = 'month', returnPath } = await request.json();
// Where to send the user after checkout. Used by the in-app upgrade modal so
// people land back on the thing they were building instead of the dashboard.
const safeReturnPath =
typeof returnPath === 'string' &&
returnPath.startsWith('/') &&
!returnPath.startsWith('//')
? returnPath
: null;
if (!plan || !['PRO', 'BUSINESS'].includes(plan)) {
return NextResponse.json(
@@ -114,8 +123,12 @@ export async function POST(request: NextRequest) {
quantity: 1,
},
],
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/pricing?canceled=true`,
success_url: safeReturnPath
? `${appUrl}${safeReturnPath}${safeReturnPath.includes('?') ? '&' : '?'}success=true&session_id={CHECKOUT_SESSION_ID}`
: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: safeReturnPath
? `${appUrl}${safeReturnPath}${safeReturnPath.includes('?') ? '&' : '?'}canceled=true`
: `${appUrl}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,

View File

@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { getPlanFromStripePriceId, stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import Stripe from 'stripe';
import { sendConversionEvent } from '@/lib/metaConversions';
import { scoreUserLifecycle } from '@/lib/revops-server';
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { getPlanFromStripePriceId, stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import Stripe from 'stripe';
import { sendConversionEvent } from '@/lib/metaConversions';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) {
const body = await request.text();
@@ -51,21 +51,21 @@ export async function POST(request: NextRequest) {
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
const updatedUser = await db.user.update({
where: {
stripeCustomerId: session.customer as string,
const updatedUser = await db.user.update({
where: {
stripeCustomerId: session.customer as string,
},
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: (session.metadata?.plan || 'FREE') as any,
},
});
await scoreUserLifecycle(updatedUser.id, 'subscription_created');
},
});
// Meta CAPI — Purchase event
await scoreUserLifecycle(updatedUser.id, 'subscription_created');
// Meta CAPI - Purchase event
const amountCents = session.amount_total ?? 0;
sendConversionEvent({
eventName: 'Purchase',
@@ -95,47 +95,47 @@ export async function POST(request: NextRequest) {
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: getPlanFromStripePriceId(subscription.items.data[0].price.id) ?? undefined,
},
});
const updated = await db.user.findUnique({
where: { stripeSubscriptionId: subscription.id },
select: { id: true },
});
if (updated?.id) {
await scoreUserLifecycle(
updated.id,
subscription.cancel_at_period_end ? 'subscription_canceled_at_period_end' : 'subscription_updated'
);
}
break;
}
await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: getPlanFromStripePriceId(subscription.items.data[0].price.id) ?? undefined,
},
});
const updated = await db.user.findUnique({
where: { stripeSubscriptionId: subscription.id },
select: { id: true },
});
if (updated?.id) {
await scoreUserLifecycle(
updated.id,
subscription.cancel_at_period_end ? 'subscription_canceled_at_period_end' : 'subscription_updated'
);
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
const updatedUser = await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
const updatedUser = await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: 'FREE',
},
});
await scoreUserLifecycle(updatedUser.id, 'subscription_deleted');
break;
}
plan: 'FREE',
},
});
await scoreUserLifecycle(updatedUser.id, 'subscription_deleted');
break;
}
}
return NextResponse.json({ received: true });

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { tiktokApi } from '@/lib/tiktok';
// Live read-only stats from the Display API. Requires the user.info.stats and
// video.list scopes accounts connected before the scope change must
// video.list scopes - accounts connected before the scope change must
// re-authorize via /api/tiktok/connect.
const USER_FIELDS = 'display_name,follower_count,following_count,likes_count,video_count';

View File

@@ -5,7 +5,7 @@ import { TIKTOK_BRAND, getValidTiktokTokens } from '@/lib/tiktok';
export async function GET(request: NextRequest) {
const adminKey = process.env.TIKTOK_ADMIN_KEY;
if (!adminKey) {
// Unlike /connect, this endpoint hands out live credentials never expose
// Unlike /connect, this endpoint hands out live credentials - never expose
// it without a configured key.
return NextResponse.json(
{ error: 'TIKTOK_ADMIN_KEY must be configured to expose TikTok status' },

View File

@@ -135,7 +135,7 @@ export async function POST(request: NextRequest) {
media_type: 'PHOTO',
// MEDIA_UPLOAD = draft in the creator's TikTok inbox (posting
// policy is upload/draft only) and only needs the video.upload
// scope QRMaster has no video.publish.
// scope - QRMaster has no video.publish.
post_mode: 'MEDIA_UPLOAD',
post_info: {
...(title ? { title } : {}),

View File

@@ -23,11 +23,14 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Count dynamic QR codes
// Count dynamic QR codes. Must match the limit check in /api/qrs - only
// ACTIVE codes consume a slot, otherwise the dashboard shows a different
// number than the API actually enforces.
const dynamicQRCount = await db.qRCode.count({
where: {
userId,
type: 'DYNAMIC',
status: 'ACTIVE',
},
});