Email retention

This commit is contained in:
Timo Knuth
2026-04-02 11:37:58 +02:00
parent 1cff96a553
commit 7afd39c18c
18 changed files with 4110 additions and 2951 deletions

View File

@@ -15,3 +15,12 @@ GOOGLE_CLIENT_SECRET=
REDIS_URL=redis://redis:6379 REDIS_URL=redis://redis:6379
IP_SALT=CHANGE_ME_SALT IP_SALT=CHANGE_ME_SALT
ENABLE_DEMO=true ENABLE_DEMO=true
# SMTP (for welcome + retention emails via nodemailer)
SMTP_HOST=smtp.qrmaster.net
SMTP_PORT=465
SMTP_USER=timo@qrmaster.net
SMTP_PASS=
# Cron job protection — generate with: openssl rand -base64 32
CRON_SECRET=

20
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@prisma/client": "^5.7.0", "@prisma/client": "^5.7.0",
"@stripe/stripe-js": "^8.0.0", "@stripe/stripe-js": "^8.0.0",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"@types/nodemailer": "^7.0.11",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
@@ -36,6 +37,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "^14.2.35", "next": "^14.2.35",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
"nodemailer": "^8.0.4",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"posthog-js": "^1.332.0", "posthog-js": "^1.332.0",
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",
@@ -4094,6 +4096,15 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pako": { "node_modules/@types/pako": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
@@ -9625,6 +9636,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",

View File

@@ -35,6 +35,7 @@
"@prisma/client": "^5.7.0", "@prisma/client": "^5.7.0",
"@stripe/stripe-js": "^8.0.0", "@stripe/stripe-js": "^8.0.0",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"@types/nodemailer": "^7.0.11",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
@@ -55,6 +56,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "^14.2.35", "next": "^14.2.35",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
"nodemailer": "^8.0.4",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"posthog-js": "^1.332.0", "posthog-js": "^1.332.0",
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",

View File

@@ -32,6 +32,11 @@ model User {
resetPasswordToken String? @unique resetPasswordToken String? @unique
resetPasswordExpires DateTime? resetPasswordExpires DateTime?
// Retention email tracking
activationNudgeSentAt DateTime?
upgradeNudgeSentAt DateTime?
thirtyDayNudgeSentAt DateTime?
qrCodes QRCode[] qrCodes QRCode[]
integrations Integration[] integrations Integration[]
accounts Account[] accounts Account[]

BIN
public/email-hero-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

View File

@@ -12,6 +12,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
import { QrCode } from 'lucide-react';
interface QRCodeData { interface QRCodeData {
id: string; id: string;
@@ -45,68 +46,6 @@ export default function DashboardPage() {
}); });
const [analyticsData, setAnalyticsData] = useState<any>(null); const [analyticsData, setAnalyticsData] = useState<any>(null);
const mockQRCodes = [
{
id: '1',
title: 'Support Phone',
type: 'DYNAMIC' as const,
contentType: 'PHONE',
slug: 'support-phone-demo',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:00:00Z',
scans: 0,
},
{
id: '2',
title: 'Event Details',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'event-details-demo',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:01:00Z',
scans: 0,
},
{
id: '3',
title: 'Product Demo',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'product-demo-qr',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:02:00Z',
scans: 0,
},
{
id: '4',
title: 'Company Website',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'company-website-qr',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:03:00Z',
scans: 0,
},
{
id: '5',
title: 'Contact Card',
type: 'DYNAMIC' as const,
contentType: 'VCARD',
slug: 'contact-card-qr',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:04:00Z',
scans: 0,
},
{
id: '6',
title: 'Event Details',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'event-details-dup',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:05:00Z',
scans: 0,
},
];
const blogPosts = [ const blogPosts = [
// NEW POSTS // NEW POSTS
@@ -384,7 +323,11 @@ export default function DashboardPage() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1> <h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
<p className="text-gray-600 mt-2">{t('dashboard.subtitle')}</p> <p className="text-gray-600 mt-2">
{!loading && qrCodes.length === 0
? 'Start here — create your first QR code in under 2 minutes'
: t('dashboard.subtitle')}
</p>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Badge variant={getPlanBadgeColor(userPlan)} className="text-lg px-4 py-2"> <Badge variant={getPlanBadgeColor(userPlan)} className="text-lg px-4 py-2">
@@ -445,6 +388,17 @@ export default function DashboardPage() {
</Card> </Card>
))} ))}
</div> </div>
) : qrCodes.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
You have 3 free dynamic QR codes. They redirect wherever you want and track every scan.
</p>
<Link href="/create">
<Button>Create QR Code it takes 90 seconds</Button>
</Link>
</div>
) : ( ) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{qrCodes.map((qr) => ( {qrCodes.map((qr) => (

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link';
import TeamsGenerator from './TeamsGenerator'; import TeamsGenerator from './TeamsGenerator';
import { Users, Shield, Zap, Video, MessageCircle, Download, Share2 } from 'lucide-react'; import { Users, Shield, Zap, Video, MessageCircle, Download, Share2 } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
@@ -250,6 +251,18 @@ export default function TeamsQRCodePage() {
</div> </div>
</section> </section>
{/* DEEP DIVE BLOG LINK */}
<section className="py-10 px-4 sm:px-6 lg:px-8 bg-white border-t border-slate-100">
<div className="max-w-3xl mx-auto text-center">
<p className="text-slate-600 text-base">
Want a deeper guide?{' '}
<Link href="/blog/microsoft-teams-qr-code" className="text-[#6264A7] font-semibold underline hover:opacity-80 transition-opacity">
How to Create a Microsoft Teams QR Code for Instant Meeting Joins
</Link>
</p>
</div>
</section>
{/* RELATED TOOLS */} {/* RELATED TOOLS */}
<RelatedTools /> <RelatedTools />

View File

@@ -7,6 +7,7 @@ import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signupSchema, validateRequest } from '@/lib/validationSchemas'; import { signupSchema, validateRequest } from '@/lib/validationSchemas';
import { sendWelcomeEmail } from '@/lib/email';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -74,6 +75,13 @@ export async function POST(request: NextRequest) {
}, },
}); });
// 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);
}
// Create response // Create response
const response = NextResponse.json({ const response = NextResponse.json({
success: true, success: true,

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { sendActivationNudgeEmail, sendUpgradeNudgeEmail, sendThirtyDayNudgeEmail } from '@/lib/email';
// 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;
if (!cronSecret) return false;
return authHeader === `Bearer ${cronSecret}`;
}
export async function GET(request: NextRequest) {
if (!isAuthorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
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);
let activationSent = 0;
let upgradeSent = 0;
let thirtyDaySent = 0;
// Day-3: signed up > 3 days ago, never created a QR code, hasn't received this email yet
const activationCandidates = await db.user.findMany({
where: {
createdAt: { lt: threeDaysAgo },
activationNudgeSentAt: null,
},
include: {
_count: { select: { qrCodes: true } },
},
});
for (const user of activationCandidates) {
if (user._count.qrCodes === 0 && user.email) {
try {
await sendActivationNudgeEmail(user.email, user.name ?? 'there');
await db.user.update({
where: { id: user.id },
data: { activationNudgeSentAt: now },
});
activationSent++;
} catch (err) {
console.error(`Activation nudge failed for ${user.email}:`, err);
}
}
}
// 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({
where: {
createdAt: { lt: sevenDaysAgo },
upgradeNudgeSentAt: null,
plan: 'FREE',
},
include: {
_count: { select: { qrCodes: 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);
}
}
}
// Day-30: signed up > 30 days ago, has ≥1 QR code, still FREE, hasn't received this email yet
const thirtyDayCandidates = await (db.user as any).findMany({
where: {
createdAt: { lt: thirtyDaysAgo },
thirtyDayNudgeSentAt: null,
plan: 'FREE',
},
include: {
_count: { select: { qrCodes: true } },
},
});
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 as any).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,
thirtyDayNudgesSent: thirtyDaySent,
});
}

View File

@@ -1,6 +1,139 @@
import type { BlogPost } from "./types"; import type { BlogPost } from "./types";
export const blogPosts: BlogPost[] = [ export const blogPosts: BlogPost[] = [
// ==================================================================================
// NEW POSTS
// ==================================================================================
{
slug: "microsoft-teams-qr-code",
title: "How to Create a Microsoft Teams QR Code for Instant Meeting Joins",
description: "Step-by-step guide to creating a QR code for any Microsoft Teams meeting. Attendees scan once to join — no link typing needed. Free tool included.",
excerpt: "Step-by-step guide to creating a QR code for any Microsoft Teams meeting. Attendees scan once to join — no link typing needed.",
category: "Business Tools",
pillar: "use-cases",
published: true,
publishDate: "2026-04-02",
date: "April 2, 2026",
datePublished: "2026-04-02T09:00:00Z",
dateModified: "2026-04-02T09:00:00Z",
updatedAt: "2026-04-02",
authorSlug: "timo",
authorName: "Timo Knuth",
authorTitle: "QR Code & Marketing Expert",
readTime: "8 Min",
image: "/blog/teams-qr-code.png",
heroImage: "/blog/teams-qr-code.png",
imageAlt: "Microsoft Teams meeting room with QR code displayed on screen",
keywords: ["microsoft teams qr code", "teams meeting qr code", "teams qr code", "join teams meeting qr", "teams besprechung qr code"],
quickAnswer: `<p>Copy your Teams meeting URL → paste it into <a href="/tools/teams-qr-code">QR Master's free Teams QR generator</a> → download the code → display it in your meeting room or invitation. Attendees scan once to join instantly — no link typing needed.</p>`,
keySteps: [
"Open your Microsoft Teams calendar and click on the meeting to copy the Join link.",
"Go to qrmaster.net/tools/teams-qr-code and paste the link.",
"Customize: choose Teams purple (#6264A7) and add a label like 'Scan to Join'.",
"Download as PNG (for digital screens) or SVG (for print quality).",
"Display on room screens, printed invitations, or office signage.",
],
faq: [
{ question: "Does the QR code work for recurring Teams meetings?", answer: "Yes — recurring Teams meetings typically reuse the same join link, so one QR code works for every session." },
{ question: "Can guests without a Teams account join via QR code?", answer: "Yes. When they scan the code, Teams opens the web client which works without a Microsoft account." },
{ question: "What's the difference between a static and dynamic Teams QR code?", answer: "A static QR code encodes the meeting link permanently. A <a href='/dynamic-qr-code-generator'>dynamic QR code</a> lets you update the link anytime — useful for room displays where meetings change." },
{ question: "How big should I print the Teams QR code?", answer: "Minimum 4×4 cm for handouts. For a 55\" room display, 200×200px is sufficient. Always test scanning from the expected distance." },
{ question: "Does this work with Teams for personal accounts?", answer: "Yes. The QR generator works for Teams work, school, and personal account meeting links." },
],
relatedSlugs: ["dynamic-vs-static-qr-codes", "qr-code-small-business"],
sources: [
{ name: "Microsoft Teams Create and join meetings", url: "https://support.microsoft.com/en-us/office/schedule-a-meeting-in-microsoft-teams-943507a9-8583-4c58-b5d2-8ec8265e04e5", accessDate: "April 2026" },
],
content: `<div class="blog-content">
<div class="post-metadata bg-blue-50 p-4 rounded-lg mb-8 border-l-4 border-blue-500">
<p class="text-sm text-gray-700">
<strong>Author:</strong> Timo Knuth, QR Code & Marketing Expert<br/>
Published: April 2, 2026
</p>
</div>
<div class="bg-green-50 border border-green-200 rounded-xl p-6 mb-8">
<h2 class="text-lg font-bold text-green-900 mt-0 mb-3">Quick answer</h2>
<p class="text-green-800 mb-0">Copy your Teams meeting join link → paste into <a href="/tools/teams-qr-code" class="text-green-700 font-semibold underline">QR Master's Teams QR generator</a> → download → display. Takes under 60 seconds.</p>
</div>
<h2>Why a QR Code for Microsoft Teams?</h2>
<p>Hybrid work has made meeting room friction a real problem. Someone arrives at a conference room, needs to join the call, and has to either find the invite email, type a 40-character URL, or wait for the organizer to send them the link again. A QR code displayed on the room's screen or printed on a table card eliminates all of that. One scan and Teams opens immediately.</p>
<p>The use cases are broader than just conference rooms:</p>
<ul>
<li><strong>Office room displays:</strong> A permanently mounted QR code in meeting rooms that links to the room's recurring standup or team channel.</li>
<li><strong>Printed event invitations:</strong> Instead of printing a URL, print a QR code. Attendees scan before the event to save the join link.</li>
<li><strong>Shared workspaces and coworking offices:</strong> Display QR codes linking to daily all-hands or onboarding calls that rotate monthly.</li>
<li><strong>Training materials:</strong> Include a QR code in printed handbooks that links to a live Q&A session.</li>
</ul>
<h2>Step-by-Step: Creating a Microsoft Teams QR Code</h2>
<ol>
<li><strong>Get the Teams meeting link.</strong> Open Microsoft Teams → Calendar → click on your meeting → click "Copy join link". The URL starts with <code>https://teams.microsoft.com/l/meetup-join/...</code></li>
<li><strong>Open the Teams QR generator.</strong> Go to <a href="/tools/teams-qr-code" class="text-blue-600 underline">qrmaster.net/tools/teams-qr-code</a>. No account needed for a basic static code.</li>
<li><strong>Paste and customize.</strong> Paste the meeting URL. For branding: use Teams purple (<code>#6264A7</code>) for the QR modules. Add a frame label — "Scan to Join" or "Join Teams Meeting" works well.</li>
<li><strong>Choose your format.</strong>
<ul>
<li><strong>PNG</strong> — for digital displays (room screens, Slack, email signatures)</li>
<li><strong>SVG</strong> — for print (scales to any size without losing quality)</li>
</ul>
</li>
<li><strong>Test before deploying.</strong> Scan with an iPhone (native camera) and an Android (Google Lens). Test from the actual scanning distance — usually 0.51.5 meters for room displays.</li>
<li><strong>Display or print.</strong> For room screens, most Teams Rooms devices support custom backgrounds or a dedicated display app. For print, minimum 4×4 cm on paper.</li>
</ol>
<h2>Static vs. Dynamic: Which Should You Use?</h2>
<p>For a one-off meeting like a client call, a <strong>static QR code</strong> is fine — it encodes the link permanently and requires no account.</p>
<p>For meeting rooms or recurring situations, use a <strong><a href="/dynamic-qr-code-generator" class="text-blue-600 underline">dynamic QR code</a></strong>. Here's why: when a recurring meeting is updated or moved to a new link, a dynamic code lets you update the destination from your dashboard without changing or reprinting the QR code. The code on the room's screen stays the same — only the link behind it changes.</p>
<p>Dynamic codes also give you scan analytics — how many people joined via QR vs. link, what device they used, and what time of day has the most scans. Useful if you're running regular events or trainings.</p>
<h2>Sizing Guide for Teams QR Codes</h2>
<table class="w-full text-sm border-collapse my-6">
<thead>
<tr class="bg-slate-100">
<th class="text-left p-3 border border-slate-200 font-semibold">Use case</th>
<th class="text-left p-3 border border-slate-200 font-semibold">Minimum size</th>
<th class="text-left p-3 border border-slate-200 font-semibold">Recommended</th>
</tr>
</thead>
<tbody>
<tr>
<td class="p-3 border border-slate-200">55" room display</td>
<td class="p-3 border border-slate-200">150×150 px</td>
<td class="p-3 border border-slate-200">250×250 px</td>
</tr>
<tr class="bg-slate-50">
<td class="p-3 border border-slate-200">Printed A4 handout</td>
<td class="p-3 border border-slate-200">4×4 cm</td>
<td class="p-3 border border-slate-200">6×6 cm</td>
</tr>
<tr>
<td class="p-3 border border-slate-200">Table card / name tent</td>
<td class="p-3 border border-slate-200">3×3 cm</td>
<td class="p-3 border border-slate-200">4×4 cm</td>
</tr>
<tr class="bg-slate-50">
<td class="p-3 border border-slate-200">Email signature</td>
<td class="p-3 border border-slate-200">80×80 px</td>
<td class="p-3 border border-slate-200">120×120 px</td>
</tr>
</tbody>
</table>
<p>Always maintain a quiet zone (white margin) of at least 4 modules around the code. Cutting into this margin causes scan failures.</p>
<h2>Common Issues and Fixes</h2>
<ul>
<li><strong>Teams opens but shows "Meeting not found":</strong> The meeting link has expired or was cancelled. If using a dynamic QR code, update the destination link in your dashboard.</li>
<li><strong>QR code scans but nothing happens on some devices:</strong> Some older Android devices need a QR scanner app. The Teams app itself includes a QR scanner under Settings → Scan QR code.</li>
<li><strong>Link too long to encode cleanly:</strong> Teams join URLs are long. Use a dynamic QR code — it encodes a short redirect URL instead, which produces a less dense, more reliably scannable code.</li>
</ul>
<p>Ready to create yours? The <a href="/tools/teams-qr-code" class="text-blue-600 underline font-semibold">Teams QR code generator is free and takes under 60 seconds →</a></p>
</div>`,
},
// ================================================================================== // ==================================================================================
// EXISTING POSTS (Refreshed) - 8 Posts // EXISTING POSTS (Refreshed) - 8 Posts
// ================================================================================== // ==================================================================================
@@ -42,6 +175,9 @@ export const blogPosts: BlogPost[] = [
{ question: "How can I track menu QR scans?", answer: "Use dynamic QR analytics (scans, locations, devices) and optionally add UTM parameters for campaign attribution." }, { question: "How can I track menu QR scans?", answer: "Use dynamic QR analytics (scans, locations, devices) and optionally add UTM parameters for campaign attribution." },
], ],
relatedSlugs: ["dynamic-vs-static-qr-codes", "qr-code-print-size-guide", "qr-code-tracking-guide-2025", "qr-code-events"], relatedSlugs: ["dynamic-vs-static-qr-codes", "qr-code-print-size-guide", "qr-code-tracking-guide-2025", "qr-code-events"],
sources: [
{ name: "National Restaurant Association State of the Industry 2022", url: "https://restaurant.org/research-and-media/media/press-releases/2022/", accessDate: "April 2026" },
],
authorName: "Timo Knuth", authorName: "Timo Knuth",
authorTitle: "QR Code & Marketing Expert", authorTitle: "QR Code & Marketing Expert",
content: `<div class="blog-content"> content: `<div class="blog-content">
@@ -64,7 +200,7 @@ export const blogPosts: BlogPost[] = [
</div> </div>
<h2>Why QR Code Menus Became a Restaurant Standard</h2> <h2>Why QR Code Menus Became a Restaurant Standard</h2>
<p>What started as a contactless safety measure during the pandemic has become a permanent fixture in the restaurant industry. According to industry data, over 60% of restaurants that adopted QR menus during 20202021 kept them afterward — not for safety reasons, but because of the business benefits.</p> <p>What started as a contactless safety measure during the pandemic has become a permanent fixture in the restaurant industry. According to industry data, over 60% of restaurants that adopted QR menus during 20202021 kept them afterward — not for safety reasons, but because of the business benefits <a href="https://restaurant.org/research-and-media/media/press-releases/2022/" target="_blank" rel="noopener noreferrer">[NRA, 2022]</a>.</p>
<p>The advantages are straightforward: a printed menu costs $38 per copy to laminate and reprint whenever items change. A QR menu costs nothing to update. For a restaurant that changes its seasonal specials every few months, that adds up fast. Beyond cost savings, QR menus open up analytics that paper never could — you can see which menu sections guests spend the most time on, when peak scanning happens during the day, and which table locations drive the most engagement.</p> <p>The advantages are straightforward: a printed menu costs $38 per copy to laminate and reprint whenever items change. A QR menu costs nothing to update. For a restaurant that changes its seasonal specials every few months, that adds up fast. Beyond cost savings, QR menus open up analytics that paper never could — you can see which menu sections guests spend the most time on, when peak scanning happens during the day, and which table locations drive the most engagement.</p>
<h2>Static vs. Dynamic QR Codes for Restaurant Menus</h2> <h2>Static vs. Dynamic QR Codes for Restaurant Menus</h2>

View File

@@ -25,6 +25,7 @@
*/ */
import { Resend } from 'resend'; import { Resend } from 'resend';
import nodemailer from 'nodemailer';
// Use a placeholder during build time, real key at runtime // Use a placeholder during build time, real key at runtime
const resendKey = process.env.RESEND_API_KEY || 're_placeholder_for_build'; const resendKey = process.env.RESEND_API_KEY || 're_placeholder_for_build';
@@ -532,3 +533,776 @@ export async function sendAIFeatureLaunchEmail(email: string) {
throw new Error('Failed to send AI feature launch email'); throw new Error('Failed to send AI feature launch email');
} }
} }
// ---------------------------------------------------------------------------
// SMTP Transport (nodemailer) — used for retention / welcome emails
// ---------------------------------------------------------------------------
function createSmtpTransport() {
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.qrmaster.net',
port: parseInt(process.env.SMTP_PORT || '465', 10),
secure: true, // port 465 = SSL
auth: {
user: process.env.SMTP_USER || 'timo@qrmaster.net',
pass: process.env.SMTP_PASS,
},
});
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
// ---------------------------------------------------------------------------
// Shared design tokens (email-safe inline styles)
// ---------------------------------------------------------------------------
const clr = {
bg: '#F5F2EC', // warm parchment wrapper
card: '#FFFFFF',
header: '#0B0D14', // near-black header
headerAccent: '#1A1D2E',
gold: '#C8A257', // warm gold accent
goldDim: '#A07E3A',
text: '#1A1A1A',
textSoft: '#5A5A5A',
textMuted: '#909090',
border: '#E8E3D8',
pillBg: '#F0EDE5',
ctaBg: '#0B0D14',
ctaText: '#FFFFFF',
};
const webFontHead = `
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:wght@300;400;500;600&display=swap');
.dm-serif { font-family: 'DM Serif Display', Georgia, 'Times New Roman', serif !important; }
.dm-sans { font-family: 'DM Sans', -apple-system, 'Helvetica Neue', Arial, sans-serif !important; }
</style>`;
function emailShell(headExtra: string, bodyContent: string): string {
return `<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
${webFontHead}
${headExtra}
</head>
<body style="margin:0;padding:0;background-color:${clr.bg};-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color:${clr.bg};padding:40px 16px 60px;">
<tr><td align="center">
<!-- Email card -->
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0"
style="max-width:560px;width:100%;background-color:${clr.card};border-radius:16px;
overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.12);">
${bodyContent}
</table>
<!-- Footer text -->
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0"
style="max-width:560px;width:100%;margin-top:28px;">
<tr>
<td style="text-align:center;padding:0 20px;">
<p style="margin:0 0 6px;font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;color:${clr.textMuted};">
<a href="${appUrl}" style="color:${clr.gold};text-decoration:none;font-weight:500;">www.qrmaster.net</a>
&nbsp;·&nbsp;
<a href="mailto:support@qrmaster.net" style="color:${clr.textMuted};text-decoration:none;">support@qrmaster.net</a>
</p>
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;color:#B0A898;">
© 2026 QR Master. You're receiving this because you created an account.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
// Dot-grid SVG encoded as data URI for header backgrounds
// Safe URL-encoded SVG without problematic internal quotes
const dotGridPattern = `url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%221.5%22%20fill%3D%22%23C8A257%22%20fill-opacity%3D%220.18%22%2F%3E%3C%2Fsvg%3E)`;
/**
* Welcome Email — sent immediately on signup (Day 0)
*/
export async function sendWelcomeEmail(email: string, name: string) {
const transport = createSmtpTransport();
const createUrl = `${appUrl}/create`;
const firstName = name.split(' ')[0];
const html = emailShell('', `
<!-- ── HEADER ── -->
<tr>
<td style="background-color:${clr.card};background-image:${dotGridPattern};
background-size:24px 24px;padding:60px 48px 50px;text-align:center;">
<!-- Wordmark Badge -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center">
<tr>
<td style="border:1px solid rgba(200,162,87,0.4);border-radius:6px;
padding:6px 16px;background-color:rgba(200,162,87,0.05);">
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;
font-weight:700;letter-spacing:3px;color:${clr.gold};
text-transform:uppercase;">QR Master</span>
</td>
</tr>
</table>
<!-- Headline -->
<h1 style="margin:36px 0 0;font-family:'DM Serif Display',Georgia,serif;
font-size:42px;font-weight:400;line-height:1.1;color:${clr.text};
letter-spacing:-0.5px;">
Welcome,<br>
<em style="color:${clr.gold};font-style:italic;">${firstName}.</em>
</h1>
<!-- Thin gold divider -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
align="center" style="margin-top:32px;">
<tr>
<td style="width:40px;height:1px;background-color:${clr.gold};opacity:0.6;"></td>
<td style="width:12px;"></td>
<td style="width:6px;height:6px;background-color:${clr.gold};border-radius:50%;
opacity:0.9;"></td>
<td style="width:12px;"></td>
<td style="width:40px;height:1px;background-color:${clr.gold};opacity:0.6;"></td>
</tr>
</table>
<p style="margin:24px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:15px;color:${clr.textSoft};letter-spacing:0.3px;">
The physical world is now your canvas.
</p>
</td>
</tr>
<!-- ── HERO IMAGE ── -->
<tr>
<td style="padding: 0; text-align: center; background-color: ${clr.card};">
<img src="${appUrl}/email-hero-light.png" width="560" style="display:block;width:100%;max-width:560px;height:auto;border-bottom:3px solid ${clr.gold};" alt="Beautiful QR Code Experience">
</td>
</tr>
<!-- ── BODY ── -->
<tr>
<td style="padding:56px 48px 0;">
<p style="margin:0 0 24px;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.8;color:${clr.text};">
Let's be honest: most QR codes are static, look terrible, and break the moment you change a link. We built QR Master to fix that.
</p>
<p style="margin:0 0 24px;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.8;color:${clr.text};">
Your account is fully activated. You now have the power to create beautiful, dynamic QR codes that adapt to your brand, never expire, and track every single scan (device, location, and time).
</p>
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.8;color:${clr.textSoft};">
To give you the perfect start, we've loaded your account with everything you need:
</p>
</td>
</tr>
<!-- ── STATS STRIP ── -->
<tr>
<td style="padding:16px 48px 32px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<!-- Stat 1 -->
<td style="padding:12px 0;text-align:center;width:31%;">
<div style="font-family:'DM Serif Display',Georgia,serif;font-size:28px;
color:${clr.text};line-height:1;">3</div>
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;
font-weight:700;color:${clr.textMuted};letter-spacing:1px;margin-top:8px;
text-transform:uppercase;">
Free Codes
</div>
</td>
<td style="width:3.5%;"></td>
<!-- Stat 2 -->
<td style="padding:12px 0;text-align:center;width:31%;">
<div style="font-family:'DM Serif Display',Georgia,serif;font-size:28px;
color:${clr.text};line-height:1;">90<span style="font-size:18px;">s</span></div>
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;
font-weight:700;color:${clr.textMuted};letter-spacing:1px;margin-top:8px;
text-transform:uppercase;">
To Create
</div>
</td>
<td style="width:3.5%;"></td>
<!-- Stat 3 -->
<td style="padding:12px 0;text-align:center;width:31%;">
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:28px;
color:${clr.text};line-height:1;">∞</div>
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;
font-weight:700;color:${clr.textMuted};letter-spacing:1px;margin-top:8px;
text-transform:uppercase;">
No Expiry
</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- ── CTA ── -->
<tr>
<td style="padding:12px 48px 56px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td align="center">
<a href="${createUrl}"
style="display:inline-block;background-color:${clr.ctaBg};color:${clr.ctaText};
text-decoration:none;padding:18px 48px;border-radius:8px;
font-family:'DM Sans',-apple-system,sans-serif;font-size:16px;
font-weight:600;letter-spacing:0.5px;
border:1px solid rgba(200,162,87,0.5);
box-shadow:0 6px 20px rgba(11,13,20,0.15);">
Design your first QR code &nbsp;→
</a>
</td>
</tr>
<tr>
<td align="center" style="padding-top:16px;">
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:13px;
color:${clr.textMuted};">No credit card required. Zero commitment.</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- ── DIVIDER ── -->
<tr>
<td style="padding:0 48px;">
<div style="border-top:1px solid ${clr.border};"></div>
</td>
</tr>
<!-- ── SIGN-OFF ── -->
<tr>
<td style="padding:40px 48px 56px;">
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:15px;color:${clr.textSoft};line-height:1.7;">
I'm thrilled to have you on board. If you have any questions, feedback, or just want to share what you've created — hit reply. I'm reading every single email.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin-top:28px;">
<tr>
<td style="padding-right:16px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="width:56px; height:56px; background-color:#0B0D14; border-radius:50%; text-align:center; vertical-align:middle; border:2px solid ${clr.border}; box-shadow:0 4px 10px rgba(0,0,0,0.05);">
<img src="${appUrl}/favicon.svg" width="32" height="32" alt="Timo" style="display:inline-block; vertical-align:middle;">
</td>
</tr>
</table>
</td>
<td>
<p style="margin:0;font-family:'DM Serif Display',Georgia,serif;
font-size:24px;font-style:italic;color:${clr.text};line-height:1.2;">
Timo
</p>
<p style="margin:4px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:11px;font-weight:700;color:${clr.textMuted};
letter-spacing:1.5px;text-transform:uppercase;">
Founder, QR Master
</p>
</td>
</tr>
</table>
</td>
</tr>
`);
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: 'Your QR Master account is ready',
html,
});
}
/**
* Activation Nudge — sent on Day 3 if user has 0 QR codes
*/
export async function sendActivationNudgeEmail(email: string, name: string) {
const transport = createSmtpTransport();
const createUrl = `${appUrl}/create`;
const firstName = name.split(' ')[0];
const steps = [
{ n: '01', label: 'Paste your URL', sub: 'or choose WiFi, vCard, Teams, and more' },
{ n: '02', label: 'Customize the design', sub: 'colors, logo, frame label — optional' },
{ n: '03', label: 'Download & use', sub: 'PNG for screen, SVG for print' },
];
const stepsHtml = steps.map(s => `
<tr>
<td style="padding:16px 0;border-bottom:1px solid ${clr.border};">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="width:36px;vertical-align:top;padding-top:2px;">
<span style="font-family:'DM Serif Display',Georgia,serif;font-size:13px;
color:${clr.gold};font-style:italic;">${s.n}</span>
</td>
<td>
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;
font-weight:600;color:${clr.text};">${s.label}</div>
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:13px;
color:${clr.textMuted};margin-top:2px;">${s.sub}</div>
</td>
</tr>
</table>
</td>
</tr>`).join('');
const html = emailShell('', `
<!-- ── HEADER ── -->
<tr>
<td style="background-color:${clr.header};background-image:${dotGridPattern};
background-size:24px 24px;padding:44px 48px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border:1px solid rgba(200,162,87,0.35);border-radius:8px;padding:7px 18px;">
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
font-weight:600;letter-spacing:3px;color:${clr.gold};
text-transform:uppercase;">QR Master</span>
</td>
</tr>
</table>
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
You haven't made one yet,<br>
<em style="color:${clr.gold};">${firstName}.</em>
</h1>
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:14px;color:rgba(255,255,255,0.45);letter-spacing:0.2px;">
3 days since signup · 0 QR codes created
</p>
</td>
</tr>
<!-- ── BODY ── -->
<tr>
<td style="padding:40px 48px 0;">
<p style="margin:0 0 8px;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.75;color:${clr.text};">
Your 3 free dynamic QR codes are still there. Unused.<br>
Dynamic means: one code, update the link anytime, every scan tracked.
</p>
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:15px;line-height:1.75;color:${clr.textSoft};">
Here's all it takes:
</p>
</td>
</tr>
<!-- ── STEPS ── -->
<tr>
<td style="padding:8px 48px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
${stepsHtml}
</table>
</td>
</tr>
<!-- ── CTA ── -->
<tr>
<td style="padding:36px 48px 44px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td align="center">
<a href="${createUrl}"
style="display:inline-block;background-color:${clr.ctaBg};color:${clr.ctaText};
text-decoration:none;padding:18px 52px;border-radius:10px;
font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;
font-weight:600;letter-spacing:0.3px;border:1px solid rgba(200,162,87,0.3);">
Make my first QR code &nbsp;→
</a>
</td>
</tr>
<tr>
<td align="center" style="padding-top:12px;">
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;
color:${clr.textMuted};">Free forever · no card needed</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- ── DIVIDER + SIGN-OFF ── -->
<tr><td style="padding:0 48px;"><div style="border-top:1px solid ${clr.border};"></div></td></tr>
<tr>
<td style="padding:28px 48px 40px;">
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:14px;color:${clr.textSoft};line-height:1.7;">
If something stopped you from getting started, just reply. I'll help directly.
</p>
<p style="margin:16px 0 0;font-family:'DM Serif Display',Georgia,serif;
font-size:17px;color:${clr.text};">Timo</p>
<p style="margin:2px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:11px;color:${clr.textMuted};letter-spacing:0.5px;">FOUNDER, QR MASTER</p>
</td>
</tr>
`);
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: "You haven't made one yet",
html,
});
}
/**
* Upgrade Nudge — sent on Day 7 if user has ≥1 QR code and is still on FREE plan
*/
export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount: number) {
const transport = createSmtpTransport();
const pricingUrl = `${appUrl}/pricing`;
const firstName = name.split(' ')[0];
const features = [
{
label: 'Dynamic QR codes',
free: '3',
pro: '50',
},
{
label: 'Logo in QR code',
free: '—',
pro: 'Your logo, centered',
},
{
label: 'Brand colors',
free: '—',
pro: 'Any color',
},
{
label: 'CSV export',
free: '✓',
pro: '✓',
},
];
const rowsHtml = features.map((f, i) => `
<tr style="background-color:${i % 2 === 0 ? '#FAFAF8' : '#FFFFFF'};">
<td style="padding:12px 16px;font-family:'DM Sans',-apple-system,sans-serif;
font-size:13px;color:${clr.textSoft};border-bottom:1px solid ${clr.border};">
${f.label}
</td>
<td style="padding:12px 16px;text-align:center;font-family:'DM Sans',-apple-system,sans-serif;
font-size:13px;color:${clr.textMuted};border-bottom:1px solid ${clr.border};">
${f.free}
</td>
<td style="padding:12px 16px;text-align:center;font-family:'DM Sans',-apple-system,sans-serif;
font-size:13px;font-weight:500;color:${clr.text};border-bottom:1px solid ${clr.border};">
${f.pro}
</td>
</tr>`).join('');
const html = emailShell('', `
<!-- ── HEADER ── -->
<tr>
<td style="background-color:${clr.header};background-image:${dotGridPattern};
background-size:24px 24px;padding:44px 48px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border:1px solid rgba(200,162,87,0.35);border-radius:8px;padding:7px 18px;">
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
font-weight:600;letter-spacing:3px;color:${clr.gold};
text-transform:uppercase;">QR Master</span>
</td>
</tr>
</table>
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
${qrCount} of 3 free codes used,<br>
<em style="color:${clr.gold};">${firstName}.</em>
</h1>
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:14px;color:rgba(255,255,255,0.45);">
Free plan · Day 7
</p>
</td>
</tr>
<!-- ── BODY ── -->
<tr>
<td style="padding:40px 48px 0;">
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.75;color:${clr.text};">
The free plan gives you 3 dynamic QR codes. That's enough to start — not enough to scale. When you hit the limit, every new campaign means replacing an old one.
</p>
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.75;color:${clr.textSoft};">
Pro removes the ceiling — and adds custom branding your free codes never have:
</p>
</td>
</tr>
<!-- ── COMPARISON TABLE ── -->
<tr>
<td style="padding:24px 48px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
style="border-radius:10px;overflow:hidden;border:1px solid ${clr.border};">
<!-- Table header -->
<tr>
<td style="padding:12px 16px;background-color:${clr.pillBg};
font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
font-weight:600;letter-spacing:1px;color:${clr.textMuted};
text-transform:uppercase;border-bottom:1px solid ${clr.border};width:45%;">
Feature
</td>
<td style="padding:12px 16px;background-color:${clr.pillBg};text-align:center;
font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
font-weight:600;letter-spacing:1px;color:${clr.textMuted};
text-transform:uppercase;border-bottom:1px solid ${clr.border};width:20%;">
Free
</td>
<td style="padding:12px 16px;background-color:${clr.header};text-align:center;
font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
font-weight:600;letter-spacing:1px;color:${clr.gold};
text-transform:uppercase;border-bottom:1px solid ${clr.border};width:35%;">
Pro
</td>
</tr>
${rowsHtml}
</table>
</td>
</tr>
<!-- ── CTA ── -->
<tr>
<td style="padding:32px 48px 44px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td align="center">
<a href="${pricingUrl}"
style="display:inline-block;background-color:${clr.ctaBg};color:${clr.ctaText};
text-decoration:none;padding:18px 52px;border-radius:10px;
font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;
font-weight:600;letter-spacing:0.3px;border:1px solid rgba(200,162,87,0.3);">
See Pro plan &nbsp;→
</a>
</td>
</tr>
<tr>
<td align="center" style="padding-top:12px;">
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;
color:${clr.textMuted};">Your free plan stays active — no pressure</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- ── DIVIDER + SIGN-OFF ── -->
<tr><td style="padding:0 48px;"><div style="border-top:1px solid ${clr.border};"></div></td></tr>
<tr>
<td style="padding:28px 48px 40px;">
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:14px;color:${clr.textSoft};line-height:1.7;">
If you have questions about what Pro unlocks, just reply here.
</p>
<p style="margin:16px 0 0;font-family:'DM Serif Display',Georgia,serif;
font-size:17px;color:${clr.text};">Timo</p>
<p style="margin:2px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:11px;color:${clr.textMuted};letter-spacing:0.5px;">FOUNDER, QR MASTER</p>
</td>
</tr>
`);
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: `You're ${qrCount >= 3 ? 'at' : `${3 - qrCount} away from`} the free limit`,
html,
});
}
/**
* 30-Day Nudge — sent on Day 30 if user has ≥1 QR code and is still on FREE plan
*/
export async function sendThirtyDayNudgeEmail(email: string, name: string, qrCount: number) {
const transport = createSmtpTransport();
const pricingUrl = `${appUrl}/pricing`;
const firstName = name.split(' ')[0];
const html = emailShell('', `
<!-- ── HEADER ── -->
<tr>
<td style="background-color:${clr.header};background-image:${dotGridPattern};
background-size:24px 24px;padding:44px 48px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border:1px solid rgba(200,162,87,0.35);border-radius:8px;padding:7px 18px;">
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
font-weight:600;letter-spacing:3px;color:${clr.gold};
text-transform:uppercase;">QR Master</span>
</td>
</tr>
</table>
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
A month in,<br>
<em style="color:${clr.gold};">${firstName}.</em>
</h1>
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:14px;color:rgba(255,255,255,0.45);">
Free plan · Day 30
</p>
</td>
</tr>
<!-- ── BODY ── -->
<tr>
<td style="padding:40px 48px 0;">
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.75;color:${clr.text};">
You've created ${qrCount} QR code${qrCount !== 1 ? 's' : ''} in your first month. That tells me you're actually using this — not just signing up to forget it.
</p>
<p style="margin:20px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.75;color:${clr.textSoft};">
The one thing I hear most from Pro users who switched after a few weeks: <em style="color:${clr.text};">they wish they'd added their brand sooner.</em> Every code they printed on free didn't have their logo. Every flyer had a generic black pattern instead of their colors.
</p>
<p style="margin:20px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.75;color:${clr.textSoft};">
Pro fixes that. Two things it unlocks that free never will:
</p>
</td>
</tr>
<!-- ── FEATURES ── -->
<tr>
<td style="padding:28px 48px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<!-- Logo row -->
<tr>
<td style="padding:24px;background-color:${clr.pillBg};border-radius:12px;
border:1px solid ${clr.border};">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="width:36px;vertical-align:top;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="width:36px;height:36px;background-color:${clr.header};border-radius:10px;
text-align:center;vertical-align:middle;font-size:18px;color:${clr.gold};">
</td>
</tr>
</table>
</td>
<td style="padding-left:14px;vertical-align:top;padding-top:2px;">
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:15px;font-weight:600;color:${clr.text};letter-spacing:-0.2px;">Your logo, inside every QR code</p>
<p style="margin:6px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:14px;color:${clr.textSoft};line-height:1.6;">
Upload once. Every code you make automatically carries your brand mark — menus, flyers, packaging, wherever.
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Spacer -->
<tr><td style="height:16px;"></td></tr>
<!-- Colors row -->
<tr>
<td style="padding:24px;background-color:${clr.pillBg};border-radius:12px;
border:1px solid ${clr.border};">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="width:36px;vertical-align:top;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="width:36px;height:36px;background-color:${clr.header};border-radius:10px;
text-align:center;vertical-align:middle;font-size:18px;color:${clr.gold};">
</td>
</tr>
</table>
</td>
<td style="padding-left:14px;vertical-align:top;padding-top:2px;">
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:15px;font-weight:600;color:${clr.text};letter-spacing:-0.2px;">Brand colors — not just black</p>
<p style="margin:6px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:14px;color:${clr.textSoft};line-height:1.6;">
Match your QR codes to your brand palette. Looks intentional. Scans the same.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- ── CTA ── -->
<tr>
<td style="padding:32px 48px 44px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td align="center">
<a href="${pricingUrl}"
style="display:inline-block;background-color:${clr.ctaBg};color:${clr.ctaText};
text-decoration:none;padding:18px 52px;border-radius:10px;
font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;
font-weight:600;letter-spacing:0.3px;border:1px solid rgba(200,162,87,0.3);">
Add my brand to QR codes &nbsp;→
</a>
</td>
</tr>
<tr>
<td align="center" style="padding-top:12px;">
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;
color:${clr.textMuted};">Your existing codes keep working — nothing breaks</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- ── DIVIDER + SIGN-OFF ── -->
<tr><td style="padding:0 48px;"><div style="border-top:1px solid ${clr.border};"></div></td></tr>
<tr>
<td style="padding:28px 48px 40px;">
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:14px;color:${clr.textSoft};line-height:1.7;">
If you ever want to talk through whether Pro makes sense for what you're building, just reply. Happy to help figure it out.
</p>
<p style="margin:16px 0 0;font-family:'DM Serif Display',Georgia,serif;
font-size:17px;color:${clr.text};">Timo</p>
<p style="margin:2px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
font-size:11px;color:${clr.textMuted};letter-spacing:0.5px;">FOUNDER, QR MASTER</p>
</td>
</tr>
`);
await transport.sendMail({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: `${firstName}, a month of QR codes — one upgrade worth making`,
html,
});
}

119
tmp_email_size_test.js Normal file
View File

@@ -0,0 +1,119 @@
// Quick size test for email templates - run with: node tmp_email_size_test.js
const src = require('fs').readFileSync('src/lib/email.ts', 'utf8');
// Extract the constants we need
const clr = {
bg: '#F5F2EC', card: '#FFFFFF', header: '#0B0D14', headerAccent: '#1A1D2E',
gold: '#C8A257', goldDim: '#A07E3A', text: '#1A1A1A', textSoft: '#5A5A5A',
textMuted: '#909090', border: '#E8E3D8', pillBg: '#F0EDE5',
ctaBg: '#0B0D14', ctaText: '#FFFFFF',
};
const dotGridPattern = `url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%221.5%22%20fill%3D%22%23C8A257%22%20fill-opacity%3D%220.18%22%2F%3E%3C%2Fsvg%3E)`;
const webFontHead = `
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:wght@300;400;500;600&display=swap');
.dm-serif { font-family: 'DM Serif Display', Georgia, 'Times New Roman', serif !important; }
.dm-sans { font-family: 'DM Sans', -apple-system, 'Helvetica Neue', Arial, sans-serif !important; }
</style>`;
const appUrl = 'https://www.qrmaster.net';
function emailShell(headExtra, bodyContent) {
return `<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
${webFontHead}
${headExtra}
</head>
<body style="margin:0;padding:0;background-color:${clr.bg};-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color:${clr.bg};padding:40px 16px 60px;">
<tr><td align="center">
<!-- Email card -->
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0"
style="max-width:560px;width:100%;background-color:${clr.card};border-radius:16px;
overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.12);">
${bodyContent}
</table>
<!-- Footer text -->
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0"
style="max-width:560px;width:100%;margin-top:28px;">
<tr>
<td style="text-align:center;padding:0 20px;">
<p style="margin:0 0 6px;font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;color:${clr.textMuted};">
<a href="${appUrl}" style="color:${clr.gold};text-decoration:none;font-weight:500;">www.qrmaster.net</a>
&nbsp;·&nbsp;
<a href="mailto:support@qrmaster.net" style="color:${clr.textMuted};text-decoration:none;">support@qrmaster.net</a>
</p>
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;color:#B0A898;">
© 2026 QR Master. You're receiving this because you created an account.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
// Build welcome email
const firstName = 'Timo';
const createUrl = `${appUrl}/create`;
const welcomeHtml = emailShell('', `
<!-- ── HEADER ── -->
<tr>
<td style="background-color:${clr.card};background-image:${dotGridPattern};
background-size:24px 24px;padding:60px 48px 50px;text-align:center;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center">
<tr>
<td style="border:1px solid rgba(200,162,87,0.4);border-radius:6px;padding:6px 16px;background-color:rgba(200,162,87,0.05);">
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;font-weight:700;letter-spacing:3px;color:${clr.gold};text-transform:uppercase;">QR Master</span>
</td>
</tr>
</table>
<h1 style="margin:36px 0 0;font-family:'DM Serif Display',Georgia,serif;font-size:42px;font-weight:400;line-height:1.1;color:${clr.text};letter-spacing:-0.5px;">
Welcome,<br>
<em style="color:${clr.gold};font-style:italic;">${firstName}.</em>
</h1>
<p style="margin:24px 0 0;font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;color:${clr.textSoft};letter-spacing:0.3px;">
The physical world is now your canvas.
</p>
</td>
</tr>
<tr>
<td style="padding: 0; text-align: center; background-color: ${clr.card};">
<img src="${appUrl}/email-hero-light.png" width="560" style="display:block;width:100%;max-width:560px;height:auto;border-bottom:3px solid ${clr.gold};" alt="Beautiful QR Code Experience">
</td>
</tr>
<tr>
<td style="padding:56px 48px 0;">
<p style="margin:0 0 24px;font-family:'DM Sans',-apple-system,sans-serif;font-size:16px;line-height:1.8;color:${clr.text};">
Let's be honest: most QR codes are static, look terrible, and break the moment you change a link. We built QR Master to fix that.
</p>
<p style="margin:0 0 24px;font-family:'DM Sans',-apple-system,sans-serif;font-size:16px;line-height:1.8;color:${clr.text};">
Your account is fully activated. You now have the power to create beautiful, dynamic QR codes that adapt to your brand, never expire, and track every single scan (device, location, and time).
</p>
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;font-size:16px;line-height:1.8;color:${clr.textSoft};">
To give you the perfect start, we've loaded your account with everything you need:
</p>
</td>
</tr>
`);
const sizeKB = Buffer.byteLength(welcomeHtml, 'utf8') / 1024;
console.log('Welcome email rendered size:', sizeKB.toFixed(1), 'KB');
console.log('Gmail clips at: 102 KB');
console.log('Status:', sizeKB < 102 ? 'OK - under limit' : 'CLIPPED - over 102KB limit!');
console.log('');
console.log('Shell overhead approx:', Buffer.byteLength(emailShell('', ''), 'utf8') / 1024, 'KB');

View File

@@ -7,5 +7,11 @@
"env": { "env": {
"NODE_OPTIONS": "--max-old-space-size=4096" "NODE_OPTIONS": "--max-old-space-size=4096"
} }
},
"crons": [
{
"path": "/api/cron/retention-emails",
"schedule": "0 10 * * *"
} }
]
} }