email marketing
This commit is contained in:
55
src/app/(main)/(marketing)/unsubscribe/page.tsx
Normal file
55
src/app/(main)/(marketing)/unsubscribe/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
|
||||
|
||||
async function unsubscribe() {
|
||||
setStatus('saving');
|
||||
|
||||
const response = await fetch('/api/marketing/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
setStatus(response.ok ? 'success' : 'error');
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[60vh] max-w-xl items-center px-6 py-20">
|
||||
<section className="w-full border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||
{status === 'success' ? (
|
||||
<>
|
||||
<h1 className="text-3xl font-semibold text-slate-900">You’re unsubscribed.</h1>
|
||||
<p className="mt-4 text-slate-600">
|
||||
You will no longer receive QR Master product and upgrade emails.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-3xl font-semibold text-slate-900">Unsubscribe from product updates?</h1>
|
||||
<p className="mt-4 text-slate-600">
|
||||
You will still receive essential account, billing, and security emails.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={unsubscribe}
|
||||
disabled={!token || status === 'saving'}
|
||||
className="mt-8 bg-slate-900 px-5 py-3 font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{status === 'saving' ? 'Unsubscribing…' : 'Unsubscribe'}
|
||||
</button>
|
||||
{status === 'error' && (
|
||||
<p className="mt-4 text-sm text-red-700">This link is invalid or has expired.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
51
src/app/(main)/api/marketing/designer-broadcast/route.ts
Normal file
51
src/app/(main)/api/marketing/designer-broadcast/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { sendDesignerAnnouncementEmail } from '@/lib/email';
|
||||
import { createMarketingUnsubscribeUrl } from '@/lib/marketingEmail';
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
/** Sends the Designer announcement to account holders who have not opted out. */
|
||||
export async function POST() {
|
||||
if (cookies().get('newsletter-admin')?.value !== 'authenticated') {
|
||||
return NextResponse.json({ error: 'Unauthorized.' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const suppressions = await db.newsletterSubscription.findMany({
|
||||
where: { status: 'unsubscribed' },
|
||||
select: { email: true },
|
||||
});
|
||||
const suppressedEmails = new Set(suppressions.map((entry) => entry.email.toLowerCase()));
|
||||
const accountUsers = await db.user.findMany({
|
||||
select: { email: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const recipients = accountUsers.filter(
|
||||
(recipient) => !suppressedEmails.has(recipient.email.toLowerCase())
|
||||
);
|
||||
|
||||
let sent = 0;
|
||||
const failed: string[] = [];
|
||||
|
||||
// Sequential sending respects the configured Resend free-tier rate limit.
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
await sendDesignerAnnouncementEmail(
|
||||
recipient.email,
|
||||
createMarketingUnsubscribeUrl(recipient.email)
|
||||
);
|
||||
sent++;
|
||||
} catch (error) {
|
||||
failed.push(recipient.email);
|
||||
console.error('Designer announcement failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ sent, failed: failed.length, total: recipients.length });
|
||||
} catch (error) {
|
||||
console.error('Designer announcement broadcast error:', error);
|
||||
return NextResponse.json({ error: 'Unable to send Designer announcement.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
25
src/app/(main)/api/marketing/unsubscribe/route.ts
Normal file
25
src/app/(main)/api/marketing/unsubscribe/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getUnsubscribeEmail } from '@/lib/marketingEmail';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { token } = await request.json();
|
||||
const email = getUnsubscribeEmail(typeof token === 'string' ? token : null);
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: 'This unsubscribe link is invalid or expired.' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.newsletterSubscription.upsert({
|
||||
where: { email },
|
||||
update: { status: 'unsubscribed', source: 'marketing-unsubscribe' },
|
||||
create: { email, status: 'unsubscribed', source: 'marketing-unsubscribe' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Marketing unsubscribe error:', error);
|
||||
return NextResponse.json({ error: 'Unable to update your email preferences.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing?.status === 'subscribed') {
|
||||
// If already subscribed, return success (idempotent)
|
||||
// Don't reveal if email exists for privacy
|
||||
return NextResponse.json({
|
||||
@@ -58,7 +58,20 @@ export async function POST(request: NextRequest) {
|
||||
message: 'Successfully subscribed to AI features newsletter!',
|
||||
alreadySubscribed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (existing?.status === 'unsubscribed') {
|
||||
await db.newsletterSubscription.update({
|
||||
where: { email },
|
||||
data: { status: 'subscribed', source: 'ai-coming-soon' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully subscribed to AI features newsletter!',
|
||||
alreadySubscribed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Create new subscription
|
||||
await db.newsletterSubscription.create({
|
||||
|
||||
@@ -559,7 +559,48 @@ function createSmtpTransport() {
|
||||
});
|
||||
}
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
|
||||
|
||||
/** Marketing announcement with a per-recipient, functional unsubscribe link. */
|
||||
export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUrl: string) {
|
||||
await waitForRateLimit();
|
||||
|
||||
const createUrl = `${appUrl}/create`;
|
||||
|
||||
await resend.emails.send({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
to: email,
|
||||
subject: 'Your QR codes can now look like your brand',
|
||||
html: `
|
||||
<!doctype html>
|
||||
<html><body style="margin:0;background:#f5f4ef;color:#1b1c19;font-family:Arial,sans-serif;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f4ef;"><tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;background:#fff;">
|
||||
<tr><td style="padding:20px 32px;border-bottom:1px solid #c4c7c7;font-size:11px;font-weight:bold;letter-spacing:2px;">QR MASTER / DESIGNER UPDATE</td></tr>
|
||||
<tr><td style="padding:42px 48px;">
|
||||
<div style="width:42px;height:1px;background:#1b1c19;margin-bottom:24px;"></div>
|
||||
<h1 style="margin:0 0 28px;font-family:Georgia,serif;font-size:38px;font-weight:normal;line-height:1.15;">Your QR codes can now look like your brand.</h1>
|
||||
<p style="font-size:16px;line-height:1.65;">Your QR codes do not have to look generic. The new QR Master Designer gives you more control over how every code looks while keeping it easy to create QR codes that scan reliably.</p>
|
||||
<div style="border-top:1px solid #e3e3de;border-bottom:1px solid #e3e3de;padding:22px 0;margin:28px 0;">
|
||||
<p style="font-size:11px;font-weight:bold;letter-spacing:2px;">DESIGNER TIERS</p>
|
||||
<p style="font-size:14px;line-height:1.6;"><strong>Free:</strong> custom colours, plus SVG and PNG downloads.<br><strong>Pro (EUR 9/mo):</strong> 4 module shapes, custom eye styles, and your logo.<br><strong>Business (EUR 29/mo):</strong> 11 module shapes, colour gradients, and saved presets for bulk uploads.</p>
|
||||
</div>
|
||||
<p style="font-size:16px;line-height:1.65;">For packaging, flyers, menus, labels, or campaigns, a consistent design makes every scan feel like part of your brand. With Business, you can save a design once and apply it across an entire bulk upload.</p>
|
||||
<a href="${createUrl}" style="display:inline-block;background:#0047ff;color:#fff;padding:15px 24px;text-decoration:none;font-size:11px;font-weight:bold;letter-spacing:1.5px;">DESIGN YOUR QR CODE</a>
|
||||
<p style="margin:30px 0 0;font-family:Georgia,serif;font-size:17px;font-style:italic;">Best, Timo</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:20px 48px 36px;border-top:1px solid #e3e3de;text-align:center;color:#747878;font-size:11px;line-height:1.6;">
|
||||
Your existing QR codes will stay active exactly as they are.<br>
|
||||
<a href="${unsubscribeUrl}" style="color:#747878;text-decoration:underline;">Unsubscribe from product updates</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr></table>
|
||||
</body></html>
|
||||
`,
|
||||
text: `Your QR codes can now look like your brand. Design your QR code: ${createUrl}\n\nUnsubscribe from product updates: ${unsubscribeUrl}`,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared design tokens (email-safe inline styles)
|
||||
|
||||
72
src/lib/marketingEmail.ts
Normal file
72
src/lib/marketingEmail.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'server-only';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 365;
|
||||
|
||||
function getSigningSecret(): string {
|
||||
const secret = process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.NEXTAUTH_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('Set EMAIL_UNSUBSCRIBE_SECRET before sending marketing email.');
|
||||
}
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
function sign(payload: string): string {
|
||||
return crypto.createHmac('sha256', getSigningSecret()).update(payload).digest('base64url');
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function createMarketingUnsubscribeUrl(email: string): string {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ email: normalizeEmail(email), expiresAt: Date.now() + TOKEN_TTL_MS })
|
||||
).toString('base64url');
|
||||
const token = `${payload}.${sign(payload)}`;
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
|
||||
|
||||
return `${appUrl}/unsubscribe?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function getUnsubscribeEmail(token: string | null | undefined): string | null {
|
||||
if (!token) return null;
|
||||
|
||||
const separator = token.lastIndexOf('.');
|
||||
if (separator <= 0 || separator === token.length - 1) return null;
|
||||
|
||||
const payload = token.slice(0, separator);
|
||||
const providedSignature = token.slice(separator + 1);
|
||||
const expectedSignature = sign(payload);
|
||||
const providedBuffer = Buffer.from(providedSignature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
|
||||
if (
|
||||
providedBuffer.length !== expectedBuffer.length ||
|
||||
!crypto.timingSafeEqual(providedBuffer, expectedBuffer)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
|
||||
email?: unknown;
|
||||
expiresAt?: unknown;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof decoded.email !== 'string' ||
|
||||
typeof decoded.expiresAt !== 'number' ||
|
||||
decoded.expiresAt < Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeEmail(decoded.email);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user