Copy overhaul + qr designs
This commit is contained in:
289
src/components/app/UpgradeModal.tsx
Normal file
289
src/components/app/UpgradeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user