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