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

@@ -0,0 +1,289 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { X, Loader2, Pause, Download, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/Button';
export type UpgradeReason = 'limit' | 'logo' | 'shapes';
export interface ActiveCodeSummary {
id: string;
title: string;
scans30d: number;
}
interface UpgradeModalProps {
open: boolean;
reason: UpgradeReason;
/** How many dynamic codes the user already has. Only used for reason="limit". */
currentCount?: number;
/** The plan limit that was hit. Only used for reason="limit". */
limit?: number;
/** Active dynamic codes, so the user can free a slot instead of paying. */
activeCodes?: ActiveCodeSummary[];
/** Called when the user pauses a code to free a slot. */
onPauseCode?: (id: string) => Promise<void>;
/** Called when the user chooses to download a static code instead. */
onDownloadStatic?: () => void;
onClose: () => void;
/** Path Stripe returns to after checkout. Defaults to the current URL. */
returnPath?: string;
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
/**
* Copy notes, so nobody softens this back into a generic paywall:
*
* The headline names the thing the user just built, not the plan they lack.
* They are buying a specific code that already exists, not an abstraction.
*
* The two secondary options are deliberate. "Pause a code" keeps them inside
* the free plan, and "download as static" hands them a working result for
* nothing. Both cost conversions. Both are what makes the closing line
* believable instead of decorative - this product does not hold printed codes
* hostage, and this is the one screen where that has to be demonstrated
* rather than claimed.
*/
function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
if (reason === 'logo') {
return {
headline: 'Your logo belongs inside this code.',
body: 'A QR code with your mark in the middle gets scanned more often than an anonymous black square, because people can see who it belongs to before they trust it.',
mechanism:
'Pro puts your logo in the center of every code you make, at the error-correction level that keeps it scannable.',
cta: 'Add my logo - €9 / month',
reassurance: 'Your current codes keep working exactly as they are.',
};
}
if (reason === 'shapes') {
return {
headline: 'This shape needs Pro.',
body: 'Rounded modules, dots and flowing styles make a code look designed instead of generated. On a menu or a flyer, that is the difference between something people scan and something they ignore.',
mechanism:
'Pro unlocks four module shapes and your own eye styles. Colors stay free on every plan.',
cta: 'Unlock shapes - €9 / month',
reassurance: 'Your current codes keep working exactly as they are.',
};
}
const next = (currentCount ?? 3) + 1;
const cap = limit ?? 3;
return {
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`,
body: `You are using all ${cap} dynamic codes on your plan. This one is built and waiting - you can keep it, or free a slot from the codes you already have.`,
mechanism: 'Pro raises the ceiling to 50 dynamic codes and adds device and location data for every scan.',
cta: 'Save this code with Pro - €9 / month',
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
};
}
export default function UpgradeModal({
open,
reason,
currentCount,
limit,
activeCodes = [],
onPauseCode,
onDownloadStatic,
onClose,
returnPath,
}: UpgradeModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const [checkoutLoading, setCheckoutLoading] = useState(false);
const [showCodeList, setShowCodeList] = useState(false);
const [pausingId, setPausingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
useEffect(() => {
if (!open) {
setShowCodeList(false);
setError(null);
}
}, [open]);
if (!open) return null;
const copy = getCopy(reason, currentCount, limit);
const handleCheckout = async () => {
setCheckoutLoading(true);
setError(null);
try {
const path =
returnPath ??
(typeof window !== 'undefined'
? window.location.pathname + window.location.search
: '/create');
const res = await fetch('/api/stripe/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
plan: 'PRO',
billingInterval: 'month',
returnPath: path,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Could not start checkout.');
}
const { url } = await res.json();
window.location.href = url;
} catch (err: any) {
setError(err?.message || 'Could not start checkout. Please try again.');
setCheckoutLoading(false);
}
};
const handlePause = async (id: string) => {
if (!onPauseCode) return;
setPausingId(id);
setError(null);
try {
await onPauseCode(id);
} catch (err: any) {
setError(err?.message || 'Could not pause that code.');
setPausingId(null);
}
};
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(4px)' }}
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
role="dialog"
aria-modal="true"
aria-labelledby="upgrade-modal-title"
>
<div className="my-8 w-full max-w-lg overflow-hidden rounded-3xl bg-white shadow-2xl">
<div className="relative border-b border-slate-100 p-6">
<button
onClick={onClose}
className="absolute right-4 top-4 text-slate-400 transition-colors hover:text-slate-600"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<h2
id="upgrade-modal-title"
className="pr-8 text-2xl font-bold leading-snug text-slate-900"
>
{copy.headline}
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600">{copy.body}</p>
<p className="mt-3 text-base leading-relaxed text-slate-600">{copy.mechanism}</p>
</div>
<div className="space-y-3 p-6">
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
{error}
</div>
)}
<Button
onClick={handleCheckout}
disabled={checkoutLoading}
className="h-12 w-full bg-primary-600 text-base font-semibold text-white hover:bg-primary-700"
>
{checkoutLoading ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
</span>
) : (
<span className="flex items-center justify-center gap-2">
{copy.cta} <ArrowRight className="h-4 w-4" />
</span>
)}
</Button>
{reason === 'limit' && onPauseCode && activeCodes.length > 0 && !showCodeList && (
<button
onClick={() => setShowCodeList(true)}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-200 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Pause className="h-4 w-4" /> Pause one of my existing codes
</button>
)}
{reason === 'limit' && showCodeList && (
<div className="rounded-lg border border-slate-200">
<p className="border-b border-slate-100 px-4 py-3 text-sm text-slate-600">
Pausing keeps the code and its scan history. It stops resolving until you
switch it back on.
</p>
<ul className="divide-y divide-slate-100">
{activeCodes.map((code) => (
<li key={code.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-900">{code.title}</p>
<p className="text-xs text-slate-500">
{code.scans30d === 0
? 'No scans in the last 30 days'
: `${code.scans30d} scan${code.scans30d === 1 ? '' : 's'} in the last 30 days`}
</p>
</div>
<Button
variant="secondary"
size="sm"
disabled={pausingId !== null}
onClick={() => handlePause(code.id)}
>
{pausingId === code.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Pause'
)}
</Button>
</li>
))}
</ul>
</div>
)}
{reason === 'limit' && onDownloadStatic && (
<button
onClick={onDownloadStatic}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-200 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Download className="h-4 w-4" /> Download it as a static code instead
</button>
)}
<p className="pt-1 text-center text-sm text-slate-500">{copy.reassurance}</p>
<button
onClick={onClose}
className="w-full py-1 text-sm text-slate-400 transition-colors hover:text-slate-600"
>
Not now
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import React, { useMemo } from 'react';
import QRCodeLib from 'qrcode';
import {
ModuleShape,
EyeFrameShape,
EyeBallShape,
moduleShapePath,
eyeFramePath,
eyeBallPath,
isInEye,
LOW_COVERAGE_SHAPES,
} from '@/lib/qr-shapes';
export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H';
export interface QRGradient {
type: 'linear' | 'radial';
from: string;
to: string;
/** Degrees, linear only. */
rotation?: number;
}
export interface StyledQRCodeProps {
value: string;
size?: number;
fgColor?: string;
bgColor?: string;
moduleShape?: ModuleShape;
eyeFrameShape?: EyeFrameShape;
eyeBallShape?: EyeBallShape;
/** Overrides fgColor for the module fill when present. */
gradient?: QRGradient | null;
eyeColor?: string | null;
errorCorrection?: ErrorCorrectionLevel;
/** Data URL. Rendered centred, with the modules behind it cleared. */
logoUrl?: string;
/** Logo edge length as a share of the whole code. Capped at 0.28. */
logoScale?: number;
/** Quiet zone in modules. Four is the spec minimum. */
margin?: number;
className?: string;
id?: string;
}
/**
* Effective error correction.
*
* Shapes that fill less of each module, and any logo, both eat into the
* redundancy the scanner relies on. Rather than letting someone print a code
* that fails in the wild, we quietly raise the level - and the UI says so, so
* this is not a hidden override.
*/
export function resolveErrorCorrection(
requested: ErrorCorrectionLevel,
moduleShape: ModuleShape,
hasLogo: boolean
): ErrorCorrectionLevel {
if (hasLogo) return 'H';
if (LOW_COVERAGE_SHAPES.includes(moduleShape)) {
return requested === 'L' || requested === 'M' ? 'Q' : requested;
}
return requested;
}
export default function StyledQRCode({
value,
size = 240,
fgColor = '#000000',
bgColor = '#FFFFFF',
moduleShape = 'square',
eyeFrameShape = 'square',
eyeBallShape = 'square',
gradient = null,
eyeColor = null,
errorCorrection = 'M',
logoUrl,
logoScale = 0.22,
margin = 4,
className,
id,
}: StyledQRCodeProps) {
const gradientId = useMemo(
() => `qrgrad-${Math.random().toString(36).slice(2, 9)}`,
[]
);
const ec = resolveErrorCorrection(errorCorrection, moduleShape, Boolean(logoUrl));
const model = useMemo(() => {
try {
const qr = QRCodeLib.create(value || ' ', { errorCorrectionLevel: ec });
return { size: qr.modules.size, data: qr.modules.data };
} catch {
return null;
}
}, [value, ec]);
if (!model) return null;
const count = model.size;
const total = count + margin * 2;
const cell = size / total;
const offset = margin * cell;
const at = (r: number, c: number): boolean => {
if (r < 0 || c < 0 || r >= count || c >= count) return false;
return model.data[r * count + c] === 1;
};
// Modules that would sit under the logo are dropped rather than painted over.
// Overpainting leaves half-modules at the edge that some scanners still try
// to read; removing them lets the error correction do its job cleanly.
const logoSpan = logoUrl ? Math.min(logoScale, 0.28) : 0;
const logoFrom = logoUrl ? Math.floor((count * (1 - logoSpan)) / 2) : -1;
const logoTo = logoUrl ? Math.ceil((count * (1 + logoSpan)) / 2) : -1;
const underLogo = (r: number, c: number) =>
Boolean(logoUrl) && r >= logoFrom && r < logoTo && c >= logoFrom && c < logoTo;
const modulePaths: string[] = [];
for (let r = 0; r < count; r++) {
for (let c = 0; c < count; c++) {
if (!at(r, c)) continue;
if (isInEye(r, c, count)) continue;
if (underLogo(r, c)) continue;
modulePaths.push(
moduleShapePath(
moduleShape,
offset + c * cell,
offset + r * cell,
cell,
{
top: at(r - 1, c),
bottom: at(r + 1, c),
left: at(r, c - 1),
right: at(r, c + 1),
},
r,
c
)
);
}
}
const eyeOrigins: [number, number][] = [
[0, 0],
[0, count - 7],
[count - 7, 0],
];
const framePaths = eyeOrigins.map(([r, c]) =>
eyeFramePath(eyeFrameShape, offset + c * cell, offset + r * cell, cell * 7)
);
const ballPaths = eyeOrigins.map(([r, c]) =>
eyeBallPath(eyeBallShape, offset + (c + 2) * cell, offset + (r + 2) * cell, cell * 3)
);
const moduleFill = gradient ? `url(#${gradientId})` : fgColor;
const eyeFill = eyeColor || moduleFill;
const logoPx = size * logoSpan;
const logoXY = (size - logoPx) / 2;
return (
<svg
id={id}
className={className}
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
xmlns="http://www.w3.org/2000/svg"
shapeRendering="geometricPrecision"
role="img"
aria-label="QR code"
>
{gradient && (
<defs>
{gradient.type === 'linear' ? (
<linearGradient
id={gradientId}
gradientTransform={`rotate(${gradient.rotation ?? 45} 0.5 0.5)`}
>
<stop offset="0%" stopColor={gradient.from} />
<stop offset="100%" stopColor={gradient.to} />
</linearGradient>
) : (
<radialGradient id={gradientId}>
<stop offset="0%" stopColor={gradient.from} />
<stop offset="100%" stopColor={gradient.to} />
</radialGradient>
)}
</defs>
)}
<rect width={size} height={size} fill={bgColor} />
<path d={modulePaths.join(' ')} fill={moduleFill} fillRule="nonzero" />
{framePaths.map((d, i) => (
<path key={`f${i}`} d={d} fill={eyeFill} fillRule="evenodd" />
))}
{ballPaths.map((d, i) => (
<path key={`b${i}`} d={d} fill={eyeFill} />
))}
{logoUrl && (
<image
href={logoUrl}
x={logoXY}
y={logoXY}
width={logoPx}
height={logoPx}
preserveAspectRatio="xMidYMid meet"
/>
)}
</svg>
);
}

View File

@@ -2,38 +2,96 @@
import React, { useEffect, useRef } from 'react';
import Link from 'next/link';
import { X, Zap, BarChart2, RefreshCw, Palette } from 'lucide-react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
export type DownloadPopupVariant =
| 'url'
| 'googleReview'
| 'wifi'
| 'vcard'
| 'crypto'
| 'social'
| 'meeting';
interface PostDownloadPopupProps {
open: boolean;
onClose: () => void;
variant?: DownloadPopupVariant;
}
const BENEFITS = [
{ icon: RefreshCw, text: 'Edit the link anytime — QR stays the same' },
{ icon: BarChart2, text: 'See who scans, when & where' },
{ icon: Palette, text: 'Custom colors, logo & frames' },
{ icon: Zap, text: 'Free plan included — upgrade anytime for more' },
];
/**
* Copy notes.
*
* The old version led with "Your QR code is downloading!" - a status message the
* browser already shows - and buried the actual argument as the third of four
* equally weighted bullets. In a list where everything looks the same, nothing
* is important.
*
* This version opens on a fact about the file that just landed in the user's
* downloads folder. Not a warning, not a pitch: a consequence of what they did
* ten seconds ago. Then it concedes that a static code is the correct choice for
* a permanent link, which is what makes the following sentence credible rather
* than salesy.
*/
const HEADLINES: Record<DownloadPopupVariant, string> = {
url: 'This code now points at that URL forever.',
googleReview: 'This code now points at that Google profile forever.',
wifi: 'This code now carries that WiFi password forever.',
vcard: 'This code now carries those contact details forever.',
crypto: 'This code now carries that wallet address forever.',
social: 'This code now points at that profile forever.',
meeting: 'This code now points at that meeting link forever.',
};
const LS_KEY = 'qrm_download_popup_seen';
const CONCESSIONS: Record<DownloadPopupVariant, string> = {
url: 'For a permanent link, that is exactly right. If the destination ever changes, you need a new code and new printed material.',
googleReview:
'For a permanent profile, that is exactly right. If your review link ever changes, you need a new code and new printed material.',
wifi: 'Worth knowing before you print it: change the password and every printed copy stops working.',
vcard: 'For details that never change, that is exactly right. New number or new job title means a new code and new cards.',
crypto: 'For a wallet you keep, that is exactly right. Move wallets and every printed copy points at the old address.',
social: 'For a handle you keep, that is exactly right. Change the handle and every printed copy leads nowhere.',
meeting:
'Worth knowing before you print it: a recurring meeting link is fine, a one-off link expires with the meeting.',
};
const LS_KEY = 'qrm_download_popup_seen_at';
const REMIND_AFTER_DAYS = 30;
export function shouldShowDownloadPopup(): boolean {
try { return !localStorage.getItem(LS_KEY); } catch { return false; }
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return true;
const seenAt = Number(raw);
if (!Number.isFinite(seenAt)) return true;
return Date.now() - seenAt > REMIND_AFTER_DAYS * 24 * 60 * 60 * 1000;
} catch {
return false;
}
}
export function markDownloadPopupSeen(): void {
try { localStorage.setItem(LS_KEY, '1'); } catch { /* ignore */ }
try {
localStorage.setItem(LS_KEY, String(Date.now()));
} catch {
/* ignore */
}
}
export default function PostDownloadPopup({ open, onClose }: PostDownloadPopupProps) {
export default function PostDownloadPopup({
open,
onClose,
variant = 'url',
}: PostDownloadPopupProps) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
markDownloadPopupSeen();
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
@@ -45,52 +103,51 @@ export default function PostDownloadPopup({ open, onClose }: PostDownloadPopupPr
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(4px)' }}
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
role="dialog"
aria-modal="true"
aria-labelledby="download-popup-title"
>
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="relative bg-gradient-to-br from-[#4F46E5] to-[#7C3AED] p-6 text-white text-center">
<div className="w-full max-w-md overflow-hidden rounded-3xl bg-white shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<div className="relative p-6">
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors"
className="absolute right-4 top-4 text-slate-400 transition-colors hover:text-slate-600"
aria-label="Close"
>
<X className="w-5 h-5" />
<X className="h-5 w-5" />
</button>
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-3">
<Zap className="w-6 h-6 text-white" />
</div>
<h2 className="text-xl font-bold">Your QR code is downloading!</h2>
<p className="text-white/80 text-sm mt-1">
Want to make it smarter for free?
<h2
id="download-popup-title"
className="pr-8 text-xl font-bold leading-snug text-slate-900"
>
{HEADLINES[variant]}
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600">
{CONCESSIONS[variant]}
</p>
<p className="mt-3 text-base leading-relaxed text-slate-600">
A free account gives you 3 dynamic codes: same image, destination
editable any time, every scan counted.
</p>
</div>
{/* Benefits */}
<div className="p-6 space-y-3">
{BENEFITS.map(({ icon: Icon, text }) => (
<div key={text} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-[#4F46E5]" />
</div>
<span className="text-sm text-slate-700">{text}</span>
</div>
))}
</div>
{/* CTAs */}
<div className="px-6 pb-6 space-y-3">
<div className="space-y-3 px-6 pb-6">
<Link href="/signup" onClick={onClose} className="block">
<Button className="w-full bg-[#4F46E5] hover:bg-[#4338CA] text-white h-12 text-base font-semibold shadow-lg">
Create Free Account
<Button className="h-12 w-full bg-[#4F46E5] text-base font-semibold text-white shadow-lg hover:bg-[#4338CA]">
Create a free account - no card
</Button>
</Link>
<button
onClick={onClose}
className="w-full text-sm text-slate-400 hover:text-slate-600 transition-colors py-1"
className="w-full py-1 text-sm text-slate-400 transition-colors hover:text-slate-600"
>
No thanks, keep it static
No thanks, static is fine
</button>
</div>
</div>