290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
'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>
|
|
);
|
|
}
|