fix V2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user