This commit is contained in:
2026-07-27 20:47:36 +02:00
parent 90dfedf098
commit ab63d4b916
9 changed files with 1199 additions and 547 deletions

View File

@@ -4,7 +4,7 @@ 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 type UpgradeReason = 'limit' | 'logo' | 'shapes' | 'business-shapes';
export interface ActiveCodeSummary {
id: string;
@@ -15,6 +15,11 @@ export interface ActiveCodeSummary {
interface UpgradeModalProps {
open: boolean;
reason: UpgradeReason;
/** The user's current plan. Determines which plan is offered next
* (Free -> Pro, Pro -> Business) and whether a paid CTA makes sense
* at all (Business/Enterprise are already at or above the ceiling
* a plain checkout button can sell). Defaults to 'FREE'. */
plan?: string;
/** 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". */
@@ -49,7 +54,7 @@ function ordinal(n: number): string {
* hostage, and this is the one screen where that has to be demonstrated
* rather than claimed.
*/
function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
function getCopy(reason: UpgradeReason, plan: string, currentCount?: number, limit?: number) {
if (reason === 'logo') {
return {
headline: 'Your logo belongs inside this code.',
@@ -58,6 +63,7 @@ function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
'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.',
ctaType: 'checkout' as const,
};
}
@@ -69,24 +75,64 @@ function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
'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.',
ctaType: 'checkout' as const,
};
}
if (reason === 'business-shapes') {
return {
headline: 'This shape needs Business.',
body: 'Classy, diamond, hexagon, plus, mosaic, liquid and star modules are the shapes agencies and larger teams use to make a code feel fully custom, not just styled.',
mechanism:
'Business unlocks every module shape, plus white-label branding and priority support. Colors stay free on every plan.',
cta: 'Unlock all shapes - €29 / month',
reassurance: 'Your current codes keep working exactly as they are.',
ctaType: 'checkout' as const,
};
}
const next = (currentCount ?? 3) + 1;
const cap = limit ?? 3;
// Free -> Pro is the default upsell. Pro -> Business and Business/Enterprise
// "already at the top" need their own copy - otherwise a Pro user who fills
// their 50 slots gets told to buy the Pro plan they already have.
if (plan === 'BUSINESS' || plan === 'ENTERPRISE') {
return {
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`,
body: `You are using all ${cap} dynamic codes on your Business plan. This one is built and waiting - you can keep it, or free a slot from the codes you already have.`,
mechanism: 'You are already on our highest self-serve plan. For a higher ceiling than 500 dynamic codes, contact us directly.',
cta: 'Contact us about a higher limit',
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
ctaType: 'contact' as const,
};
}
if (plan === 'PRO') {
return {
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`,
body: `You are using all ${cap} dynamic codes on your Pro plan. This one is built and waiting - you can keep it, or free a slot from the codes you already have.`,
mechanism: 'Business raises the ceiling to 500 dynamic codes and adds bulk creation and priority support.',
cta: 'Save this code with Business - €29 / month',
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
ctaType: 'checkout' as const,
};
}
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.`,
ctaType: 'checkout' as const,
};
}
export default function UpgradeModal({
open,
reason,
plan = 'FREE',
currentCount,
limit,
activeCodes = [],
@@ -119,7 +165,15 @@ export default function UpgradeModal({
if (!open) return null;
const copy = getCopy(reason, currentCount, limit);
const copy = getCopy(reason, plan, currentCount, limit);
// Which plan a checkout should sell depends on where the user already is,
// not just why the modal opened. A Pro user hitting their code limit needs
// Business, not another Pro subscription.
const targetPlan =
reason === 'business-shapes' || (reason === 'limit' && plan === 'PRO')
? 'BUSINESS'
: 'PRO';
const handleCheckout = async () => {
setCheckoutLoading(true);
@@ -135,7 +189,7 @@ export default function UpgradeModal({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
plan: 'PRO',
plan: targetPlan,
billingInterval: 'month',
returnPath: path,
}),
@@ -205,21 +259,30 @@ export default function UpgradeModal({
</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>
{copy.ctaType === 'contact' ? (
<a
href="mailto:support@qrmaster.net?subject=Higher%20dynamic%20QR%20limit"
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary-600 text-base font-semibold text-white transition-colors hover:bg-primary-700"
>
{copy.cta} <ArrowRight className="h-4 w-4" />
</a>
) : (
<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