Copy overhaul + qr designs
This commit is contained in:
289
src/components/app/UpgradeModal.tsx
Normal file
289
src/components/app/UpgradeModal.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
220
src/components/generator/StyledQRCode.tsx
Normal file
220
src/components/generator/StyledQRCode.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import QRCodeLib from 'qrcode';
|
||||
import {
|
||||
ModuleShape,
|
||||
EyeFrameShape,
|
||||
EyeBallShape,
|
||||
moduleShapePath,
|
||||
eyeFramePath,
|
||||
eyeBallPath,
|
||||
isInEye,
|
||||
LOW_COVERAGE_SHAPES,
|
||||
} from '@/lib/qr-shapes';
|
||||
|
||||
export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H';
|
||||
|
||||
export interface QRGradient {
|
||||
type: 'linear' | 'radial';
|
||||
from: string;
|
||||
to: string;
|
||||
/** Degrees, linear only. */
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
export interface StyledQRCodeProps {
|
||||
value: string;
|
||||
size?: number;
|
||||
fgColor?: string;
|
||||
bgColor?: string;
|
||||
moduleShape?: ModuleShape;
|
||||
eyeFrameShape?: EyeFrameShape;
|
||||
eyeBallShape?: EyeBallShape;
|
||||
/** Overrides fgColor for the module fill when present. */
|
||||
gradient?: QRGradient | null;
|
||||
eyeColor?: string | null;
|
||||
errorCorrection?: ErrorCorrectionLevel;
|
||||
/** Data URL. Rendered centred, with the modules behind it cleared. */
|
||||
logoUrl?: string;
|
||||
/** Logo edge length as a share of the whole code. Capped at 0.28. */
|
||||
logoScale?: number;
|
||||
/** Quiet zone in modules. Four is the spec minimum. */
|
||||
margin?: number;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective error correction.
|
||||
*
|
||||
* Shapes that fill less of each module, and any logo, both eat into the
|
||||
* redundancy the scanner relies on. Rather than letting someone print a code
|
||||
* that fails in the wild, we quietly raise the level - and the UI says so, so
|
||||
* this is not a hidden override.
|
||||
*/
|
||||
export function resolveErrorCorrection(
|
||||
requested: ErrorCorrectionLevel,
|
||||
moduleShape: ModuleShape,
|
||||
hasLogo: boolean
|
||||
): ErrorCorrectionLevel {
|
||||
if (hasLogo) return 'H';
|
||||
if (LOW_COVERAGE_SHAPES.includes(moduleShape)) {
|
||||
return requested === 'L' || requested === 'M' ? 'Q' : requested;
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
export default function StyledQRCode({
|
||||
value,
|
||||
size = 240,
|
||||
fgColor = '#000000',
|
||||
bgColor = '#FFFFFF',
|
||||
moduleShape = 'square',
|
||||
eyeFrameShape = 'square',
|
||||
eyeBallShape = 'square',
|
||||
gradient = null,
|
||||
eyeColor = null,
|
||||
errorCorrection = 'M',
|
||||
logoUrl,
|
||||
logoScale = 0.22,
|
||||
margin = 4,
|
||||
className,
|
||||
id,
|
||||
}: StyledQRCodeProps) {
|
||||
const gradientId = useMemo(
|
||||
() => `qrgrad-${Math.random().toString(36).slice(2, 9)}`,
|
||||
[]
|
||||
);
|
||||
|
||||
const ec = resolveErrorCorrection(errorCorrection, moduleShape, Boolean(logoUrl));
|
||||
|
||||
const model = useMemo(() => {
|
||||
try {
|
||||
const qr = QRCodeLib.create(value || ' ', { errorCorrectionLevel: ec });
|
||||
return { size: qr.modules.size, data: qr.modules.data };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [value, ec]);
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
const count = model.size;
|
||||
const total = count + margin * 2;
|
||||
const cell = size / total;
|
||||
const offset = margin * cell;
|
||||
|
||||
const at = (r: number, c: number): boolean => {
|
||||
if (r < 0 || c < 0 || r >= count || c >= count) return false;
|
||||
return model.data[r * count + c] === 1;
|
||||
};
|
||||
|
||||
// Modules that would sit under the logo are dropped rather than painted over.
|
||||
// Overpainting leaves half-modules at the edge that some scanners still try
|
||||
// to read; removing them lets the error correction do its job cleanly.
|
||||
const logoSpan = logoUrl ? Math.min(logoScale, 0.28) : 0;
|
||||
const logoFrom = logoUrl ? Math.floor((count * (1 - logoSpan)) / 2) : -1;
|
||||
const logoTo = logoUrl ? Math.ceil((count * (1 + logoSpan)) / 2) : -1;
|
||||
const underLogo = (r: number, c: number) =>
|
||||
Boolean(logoUrl) && r >= logoFrom && r < logoTo && c >= logoFrom && c < logoTo;
|
||||
|
||||
const modulePaths: string[] = [];
|
||||
for (let r = 0; r < count; r++) {
|
||||
for (let c = 0; c < count; c++) {
|
||||
if (!at(r, c)) continue;
|
||||
if (isInEye(r, c, count)) continue;
|
||||
if (underLogo(r, c)) continue;
|
||||
modulePaths.push(
|
||||
moduleShapePath(
|
||||
moduleShape,
|
||||
offset + c * cell,
|
||||
offset + r * cell,
|
||||
cell,
|
||||
{
|
||||
top: at(r - 1, c),
|
||||
bottom: at(r + 1, c),
|
||||
left: at(r, c - 1),
|
||||
right: at(r, c + 1),
|
||||
},
|
||||
r,
|
||||
c
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const eyeOrigins: [number, number][] = [
|
||||
[0, 0],
|
||||
[0, count - 7],
|
||||
[count - 7, 0],
|
||||
];
|
||||
|
||||
const framePaths = eyeOrigins.map(([r, c]) =>
|
||||
eyeFramePath(eyeFrameShape, offset + c * cell, offset + r * cell, cell * 7)
|
||||
);
|
||||
const ballPaths = eyeOrigins.map(([r, c]) =>
|
||||
eyeBallPath(eyeBallShape, offset + (c + 2) * cell, offset + (r + 2) * cell, cell * 3)
|
||||
);
|
||||
|
||||
const moduleFill = gradient ? `url(#${gradientId})` : fgColor;
|
||||
const eyeFill = eyeColor || moduleFill;
|
||||
|
||||
const logoPx = size * logoSpan;
|
||||
const logoXY = (size - logoPx) / 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
id={id}
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
shapeRendering="geometricPrecision"
|
||||
role="img"
|
||||
aria-label="QR code"
|
||||
>
|
||||
{gradient && (
|
||||
<defs>
|
||||
{gradient.type === 'linear' ? (
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientTransform={`rotate(${gradient.rotation ?? 45} 0.5 0.5)`}
|
||||
>
|
||||
<stop offset="0%" stopColor={gradient.from} />
|
||||
<stop offset="100%" stopColor={gradient.to} />
|
||||
</linearGradient>
|
||||
) : (
|
||||
<radialGradient id={gradientId}>
|
||||
<stop offset="0%" stopColor={gradient.from} />
|
||||
<stop offset="100%" stopColor={gradient.to} />
|
||||
</radialGradient>
|
||||
)}
|
||||
</defs>
|
||||
)}
|
||||
|
||||
<rect width={size} height={size} fill={bgColor} />
|
||||
|
||||
<path d={modulePaths.join(' ')} fill={moduleFill} fillRule="nonzero" />
|
||||
|
||||
{framePaths.map((d, i) => (
|
||||
<path key={`f${i}`} d={d} fill={eyeFill} fillRule="evenodd" />
|
||||
))}
|
||||
{ballPaths.map((d, i) => (
|
||||
<path key={`b${i}`} d={d} fill={eyeFill} />
|
||||
))}
|
||||
|
||||
{logoUrl && (
|
||||
<image
|
||||
href={logoUrl}
|
||||
x={logoXY}
|
||||
y={logoXY}
|
||||
width={logoPx}
|
||||
height={logoPx}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,38 +2,96 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { X, Zap, BarChart2, RefreshCw, Palette } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export type DownloadPopupVariant =
|
||||
| 'url'
|
||||
| 'googleReview'
|
||||
| 'wifi'
|
||||
| 'vcard'
|
||||
| 'crypto'
|
||||
| 'social'
|
||||
| 'meeting';
|
||||
|
||||
interface PostDownloadPopupProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
variant?: DownloadPopupVariant;
|
||||
}
|
||||
|
||||
const BENEFITS = [
|
||||
{ icon: RefreshCw, text: 'Edit the link anytime — QR stays the same' },
|
||||
{ icon: BarChart2, text: 'See who scans, when & where' },
|
||||
{ icon: Palette, text: 'Custom colors, logo & frames' },
|
||||
{ icon: Zap, text: 'Free plan included — upgrade anytime for more' },
|
||||
];
|
||||
/**
|
||||
* Copy notes.
|
||||
*
|
||||
* The old version led with "Your QR code is downloading!" - a status message the
|
||||
* browser already shows - and buried the actual argument as the third of four
|
||||
* equally weighted bullets. In a list where everything looks the same, nothing
|
||||
* is important.
|
||||
*
|
||||
* This version opens on a fact about the file that just landed in the user's
|
||||
* downloads folder. Not a warning, not a pitch: a consequence of what they did
|
||||
* ten seconds ago. Then it concedes that a static code is the correct choice for
|
||||
* a permanent link, which is what makes the following sentence credible rather
|
||||
* than salesy.
|
||||
*/
|
||||
const HEADLINES: Record<DownloadPopupVariant, string> = {
|
||||
url: 'This code now points at that URL forever.',
|
||||
googleReview: 'This code now points at that Google profile forever.',
|
||||
wifi: 'This code now carries that WiFi password forever.',
|
||||
vcard: 'This code now carries those contact details forever.',
|
||||
crypto: 'This code now carries that wallet address forever.',
|
||||
social: 'This code now points at that profile forever.',
|
||||
meeting: 'This code now points at that meeting link forever.',
|
||||
};
|
||||
|
||||
const LS_KEY = 'qrm_download_popup_seen';
|
||||
const CONCESSIONS: Record<DownloadPopupVariant, string> = {
|
||||
url: 'For a permanent link, that is exactly right. If the destination ever changes, you need a new code and new printed material.',
|
||||
googleReview:
|
||||
'For a permanent profile, that is exactly right. If your review link ever changes, you need a new code and new printed material.',
|
||||
wifi: 'Worth knowing before you print it: change the password and every printed copy stops working.',
|
||||
vcard: 'For details that never change, that is exactly right. New number or new job title means a new code and new cards.',
|
||||
crypto: 'For a wallet you keep, that is exactly right. Move wallets and every printed copy points at the old address.',
|
||||
social: 'For a handle you keep, that is exactly right. Change the handle and every printed copy leads nowhere.',
|
||||
meeting:
|
||||
'Worth knowing before you print it: a recurring meeting link is fine, a one-off link expires with the meeting.',
|
||||
};
|
||||
|
||||
const LS_KEY = 'qrm_download_popup_seen_at';
|
||||
const REMIND_AFTER_DAYS = 30;
|
||||
|
||||
export function shouldShowDownloadPopup(): boolean {
|
||||
try { return !localStorage.getItem(LS_KEY); } catch { return false; }
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return true;
|
||||
const seenAt = Number(raw);
|
||||
if (!Number.isFinite(seenAt)) return true;
|
||||
return Date.now() - seenAt > REMIND_AFTER_DAYS * 24 * 60 * 60 * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markDownloadPopupSeen(): void {
|
||||
try { localStorage.setItem(LS_KEY, '1'); } catch { /* ignore */ }
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, String(Date.now()));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export default function PostDownloadPopup({ open, onClose }: PostDownloadPopupProps) {
|
||||
export default function PostDownloadPopup({
|
||||
open,
|
||||
onClose,
|
||||
variant = 'url',
|
||||
}: PostDownloadPopupProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
markDownloadPopupSeen();
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
@@ -45,52 +103,51 @@ export default function PostDownloadPopup({ open, onClose }: PostDownloadPopupPr
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
|
||||
onClick={(e) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="download-popup-title"
|
||||
>
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative bg-gradient-to-br from-[#4F46E5] to-[#7C3AED] p-6 text-white text-center">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-3xl bg-white shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="relative p-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors"
|
||||
className="absolute right-4 top-4 text-slate-400 transition-colors hover:text-slate-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-3">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold">Your QR code is downloading!</h2>
|
||||
<p className="text-white/80 text-sm mt-1">
|
||||
Want to make it smarter — for free?
|
||||
|
||||
<h2
|
||||
id="download-popup-title"
|
||||
className="pr-8 text-xl font-bold leading-snug text-slate-900"
|
||||
>
|
||||
{HEADLINES[variant]}
|
||||
</h2>
|
||||
|
||||
<p className="mt-3 text-base leading-relaxed text-slate-600">
|
||||
{CONCESSIONS[variant]}
|
||||
</p>
|
||||
|
||||
<p className="mt-3 text-base leading-relaxed text-slate-600">
|
||||
A free account gives you 3 dynamic codes: same image, destination
|
||||
editable any time, every scan counted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Benefits */}
|
||||
<div className="p-6 space-y-3">
|
||||
{BENEFITS.map(({ icon: Icon, text }) => (
|
||||
<div key={text} className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-4 h-4 text-[#4F46E5]" />
|
||||
</div>
|
||||
<span className="text-sm text-slate-700">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="px-6 pb-6 space-y-3">
|
||||
<div className="space-y-3 px-6 pb-6">
|
||||
<Link href="/signup" onClick={onClose} className="block">
|
||||
<Button className="w-full bg-[#4F46E5] hover:bg-[#4338CA] text-white h-12 text-base font-semibold shadow-lg">
|
||||
Create Free Account
|
||||
<Button className="h-12 w-full bg-[#4F46E5] text-base font-semibold text-white shadow-lg hover:bg-[#4338CA]">
|
||||
Create a free account - no card
|
||||
</Button>
|
||||
</Link>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full text-sm text-slate-400 hover:text-slate-600 transition-colors py-1"
|
||||
className="w-full py-1 text-sm text-slate-400 transition-colors hover:text-slate-600"
|
||||
>
|
||||
No thanks, keep it static
|
||||
No thanks, static is fine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user