'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; /** 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(null); const [checkoutLoading, setCheckoutLoading] = useState(false); const [showCodeList, setShowCodeList] = useState(false); const [pausingId, setPausingId] = useState(null); const [error, setError] = useState(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 (
{ if (e.target === overlayRef.current) onClose(); }} role="dialog" aria-modal="true" aria-labelledby="upgrade-modal-title" >

{copy.headline}

{copy.body}

{copy.mechanism}

{error && (
{error}
)} {reason === 'limit' && onPauseCode && activeCodes.length > 0 && !showCodeList && ( )} {reason === 'limit' && showCodeList && (

Pausing keeps the code and its scan history. It stops resolving until you switch it back on.

    {activeCodes.map((code) => (
  • {code.title}

    {code.scans30d === 0 ? 'No scans in the last 30 days' : `${code.scans30d} scan${code.scans30d === 1 ? '' : 's'} in the last 30 days`}

  • ))}
)} {reason === 'limit' && onDownloadStatic && ( )}

{copy.reassurance}

); }