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

View File

@@ -1,16 +1,16 @@
'use client';
import React from 'react';
import { QRCodeSVG } from 'qrcode.react';
import Barcode from 'react-barcode';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { formatDate } from '@/lib/utils';
import {
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
} from '@/lib/revops';
import StyledQRCode from '@/components/generator/StyledQRCode';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { formatDate } from '@/lib/utils';
import {
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
} from '@/lib/revops';
function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string {
const cloned = svgElement.cloneNode(true) as SVGElement;
@@ -63,21 +63,21 @@ interface QRCodeCardProps {
onDelete: (id: string) => void;
}
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr,
onEdit,
onDelete,
}) => {
const markDownloadComplete = () => {
if (typeof window === 'undefined') {
return;
}
localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1');
window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT));
};
// For dynamic QR codes, use the redirect URL for tracking
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr,
onEdit,
onDelete,
}) => {
const markDownloadComplete = () => {
if (typeof window === 'undefined') {
return;
}
localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1');
window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT));
};
// For dynamic QR codes, use the redirect URL for tracking
// For static QR codes, use the direct URL from content
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
@@ -138,11 +138,11 @@ END:VCARD`;
pixelRatio: 3,
backgroundColor: '#ffffff' // White background for clean export
});
const link = document.createElement('a');
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
link.href = dataUrl;
link.click();
markDownloadComplete();
const link = document.createElement('a');
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
link.href = dataUrl;
link.click();
markDownloadComplete();
} else {
// For SVG, if no frame, export just the QR code SVG for vector quality
// If frame exists, use toPng as fallback since HTML-to-SVG is complex
@@ -153,11 +153,11 @@ END:VCARD`;
pixelRatio: 3,
backgroundColor: '#ffffff'
});
const link = document.createElement('a');
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
link.href = dataUrl;
link.click();
markDownloadComplete();
const link = document.createElement('a');
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
link.href = dataUrl;
link.click();
markDownloadComplete();
} else {
// No frame - export clean SVG from the svg wrapper
const svgContainer = document.querySelector(`#qr-svg-${qr.id}`);
@@ -185,12 +185,12 @@ END:VCARD`;
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
a.click();
URL.revokeObjectURL(url);
markDownloadComplete();
const a = document.createElement('a');
a.href = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
a.click();
URL.revokeObjectURL(url);
markDownloadComplete();
}
}
}
@@ -200,16 +200,16 @@ END:VCARD`;
};
return (
<Card hover className="rounded-[24px] border-slate-200 p-0 shadow-none">
<CardContent className="p-4">
<Card hover className="rounded-[24px] border-slate-200 p-0 shadow-none">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
<div className="flex items-center space-x-2">
<Badge className={qr.type === 'DYNAMIC' ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-700'}>
{qr.type}
</Badge>
</div>
<Badge className={qr.type === 'DYNAMIC' ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-700'}>
{qr.type}
</Badge>
</div>
</div>
<Dropdown
@@ -234,12 +234,12 @@ END:VCARD`;
</Dropdown>
</div>
<div className="mb-3 flex flex-col items-center justify-center rounded-[20px] bg-slate-50 p-4">
{/* Download wrapper - tightly wraps content */}
<div
id={`qr-download-${qr.id}`}
className="inline-flex flex-col items-center rounded-[20px] border border-slate-100 bg-white p-4"
>
<div className="mb-3 flex flex-col items-center justify-center rounded-[20px] bg-slate-50 p-4">
{/* Download wrapper - tightly wraps content */}
<div
id={`qr-download-${qr.id}`}
className="inline-flex flex-col items-center rounded-[20px] border border-slate-100 bg-white p-4"
>
{/* Frame Label */}
{qr.style?.frameType && qr.style.frameType !== 'none' && (
<div
@@ -284,18 +284,31 @@ END:VCARD`;
</p>
</div>
) : (
<QRCodeSVG
/* Same renderer as the detail page and the bulk export.
qrcode.react only knows two colours, so a code saved with a
module shape, gradient or logo showed up here as a plain
black grid - the design looked lost even when it was stored. */
<StyledQRCode
value={qrUrl}
size={96}
fgColor={qr.style?.foregroundColor || '#000000'}
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
level="H"
imageSettings={qr.style?.imageSettings ? {
src: qr.style.imageSettings.src,
height: qr.style.imageSettings.height * (96 / 200),
width: qr.style.imageSettings.width * (96 / 200),
excavate: qr.style.imageSettings.excavate,
} : undefined}
moduleShape={qr.style?.moduleShape || 'square'}
eyeFrameShape={qr.style?.eyeFrameShape || 'square'}
eyeBallShape={qr.style?.eyeBallShape || 'square'}
gradient={
qr.style?.gradientMode && qr.style.gradientMode !== 'none'
? {
type: qr.style.gradientMode,
from: qr.style.foregroundColor || '#000000',
to: qr.style.gradientTo || '#000000',
}
: null
}
errorCorrection="H"
logoUrl={qr.style?.imageSettings?.src}
logoScale={(qr.style?.imageSettings?.width ?? 24) / 200}
margin={0}
/>
)}
</div>
@@ -318,7 +331,7 @@ END:VCARD`;
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
</div>
{qr.type === 'DYNAMIC' && (
<div className="border-t border-slate-100 pt-2">
<div className="border-t border-slate-100 pt-2">
<p className="text-xs text-gray-500">
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
</p>
@@ -337,4 +350,4 @@ END:VCARD`;
</CardContent>
</Card>
);
};
};