Copy overhaul + qr designs
This commit is contained in:
@@ -130,7 +130,7 @@ export default function AppLayout({
|
||||
},
|
||||
{
|
||||
name: t('nav.pricing'),
|
||||
href: '/pricing',
|
||||
href: '/upgrade',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
@@ -164,11 +164,11 @@ export default function AppLayout({
|
||||
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform lg:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<img src="/favicon1.png" alt="QR Master" className="w-16 h-16 rounded-full object-cover" />
|
||||
<span className="text-xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-16 h-16 rounded-full object-cover" />
|
||||
<span className="text-xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<button
|
||||
className="lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
@@ -200,10 +200,10 @@ export default function AppLayout({
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:ml-64">
|
||||
{/* Top bar */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="lg:ml-64">
|
||||
{/* Top bar */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
className="lg:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
@@ -213,24 +213,24 @@ export default function AppLayout({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
{/* User Menu */}
|
||||
<Dropdown
|
||||
align="right"
|
||||
trigger={
|
||||
<button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900">
|
||||
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-primary-600">
|
||||
{getUserInitials()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden md:block font-medium">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
{/* User Menu */}
|
||||
<Dropdown
|
||||
align="right"
|
||||
trigger={
|
||||
<button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900">
|
||||
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-primary-600">
|
||||
{getUserInitials()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden md:block font-medium">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={handleSignOut}>
|
||||
@@ -242,13 +242,13 @@ export default function AppLayout({
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-6">
|
||||
{children}
|
||||
</main>
|
||||
<main className="p-6">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer variant="dashboard" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import Papa from 'papaparse';
|
||||
import ExcelJS from 'exceljs';
|
||||
@@ -8,7 +9,8 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
@@ -39,6 +41,38 @@ export default function BulkCreationPage() {
|
||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||
const [isDynamic, setIsDynamic] = useState(false);
|
||||
const [remainingDynamic, setRemainingDynamic] = useState(0);
|
||||
// Rows the API refused. Previously these vanished silently and the success
|
||||
// toast reported a smaller number with no explanation - the worst kind of
|
||||
// failure for someone who is about to send a batch to print.
|
||||
const [failedRows, setFailedRows] = useState<{ row: number; title: string; reason: string }[]>([]);
|
||||
// A saved design applied to the whole batch. This is the reason presets exist:
|
||||
// 500 codes that all look like the same client, from one upload.
|
||||
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
|
||||
const [presetId, setPresetId] = useState('');
|
||||
|
||||
// Reload the remaining dynamic quota from the server. Counting down locally
|
||||
// drifts as soon as anything is created in another tab, which is how rows
|
||||
// ended up being refused mid-batch in the first place.
|
||||
const refreshQuota = async () => {
|
||||
try {
|
||||
const statsRes = await fetch('/api/user/stats');
|
||||
if (statsRes.ok) {
|
||||
const stats = await statsRes.json();
|
||||
setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing quota:', error);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch('/api/design-presets')
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((d) => Array.isArray(d) && setPresets(d))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const activeStyle = () => presets.find((p) => p.id === presetId)?.style ?? null;
|
||||
|
||||
// Check user plan and dynamic quota on mount
|
||||
React.useEffect(() => {
|
||||
@@ -177,15 +211,15 @@ export default function BulkCreationPage() {
|
||||
|
||||
// Use qrcode library to generate SVG
|
||||
const QRCode = require('qrcode');
|
||||
const qrSvg = await QRCode.toString(content, {
|
||||
type: 'svg',
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
const style = activeStyle();
|
||||
const qrSvg = style
|
||||
? renderStyledQRSvg(String(content), style, 300)
|
||||
: await QRCode.toString(content, {
|
||||
type: 'svg',
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#FFFFFF' },
|
||||
});
|
||||
|
||||
qrCodes.push({
|
||||
title: String(title),
|
||||
@@ -212,20 +246,35 @@ export default function BulkCreationPage() {
|
||||
const toProcess = remainingDynamic > 0 ? data.slice(0, remainingDynamic) : [];
|
||||
|
||||
if (toProcess.length === 0) {
|
||||
showToast('Du hast keine dynamischen QR-Codes mehr übrig. Bitte upgrade deinen Plan.', 'error');
|
||||
showToast('No dynamic QR codes left on your plan. Free a slot or upgrade to continue.', 'error');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length > remainingDynamic) {
|
||||
showToast(`Nur ${remainingDynamic} dynamische Codes verfügbar. Es werden nur die ersten ${remainingDynamic} Zeilen verarbeitet.`, 'warning');
|
||||
showToast(
|
||||
`Only ${remainingDynamic} dynamic codes left. The first ${remainingDynamic} rows will be processed - the rest are listed after the run.`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const QRCode = require('qrcode');
|
||||
const results: GeneratedQR[] = [];
|
||||
const failures: { row: number; title: string; reason: string }[] = [];
|
||||
|
||||
for (const row of toProcess) {
|
||||
// Rows the plan could not cover are counted from the start, so the summary
|
||||
// reflects the whole upload and not just the slice we attempted.
|
||||
data.slice(toProcess.length).forEach((row, i) => {
|
||||
failures.push({
|
||||
row: toProcess.length + i + 1,
|
||||
title: String(row[mapping.title as keyof typeof row] || 'Untitled'),
|
||||
reason: 'No dynamic code slots left on your plan',
|
||||
});
|
||||
});
|
||||
|
||||
for (let i = 0; i < toProcess.length; i++) {
|
||||
const row = toProcess[i];
|
||||
const title = String(row[mapping.title as keyof typeof row] || 'Untitled');
|
||||
const url = String(row[mapping.content as keyof typeof row] || 'https://example.com');
|
||||
|
||||
@@ -242,23 +291,61 @@ export default function BulkCreationPage() {
|
||||
if (res.ok) {
|
||||
const qr = await res.json();
|
||||
const redirectUrl = `${window.location.origin}/r/${qr.slug}`;
|
||||
const svg = await QRCode.toString(redirectUrl, { type: 'svg', width: 300, margin: 2 });
|
||||
const style = activeStyle();
|
||||
const svg = style
|
||||
? renderStyledQRSvg(redirectUrl, style, 300)
|
||||
: await QRCode.toString(redirectUrl, { type: 'svg', width: 300, margin: 2 });
|
||||
results.push({ title, content: url, svg, slug: qr.slug, redirectUrl });
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
failures.push({
|
||||
row: i + 1,
|
||||
title,
|
||||
reason: err?.error === 'Limit reached'
|
||||
? 'No dynamic code slots left on your plan'
|
||||
: err?.error || `Request failed (${res.status})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setGeneratedQRs(results);
|
||||
setRemainingDynamic(prev => Math.max(0, prev - results.length));
|
||||
setFailedRows(failures);
|
||||
await refreshQuota();
|
||||
setStep('complete');
|
||||
showToast(`${results.length} dynamische QR-Codes erstellt!`, 'success');
|
||||
|
||||
if (failures.length > 0) {
|
||||
showToast(
|
||||
`${results.length} of ${data.length} codes created. ${failures.length} row${failures.length === 1 ? '' : 's'} could not be added - see the list below.`,
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
showToast(`${results.length} dynamic QR codes created.`, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Dynamic QR generation error:', error);
|
||||
showToast('Fehler beim Erstellen der dynamischen QR-Codes', 'error');
|
||||
showToast('Something went wrong while creating the dynamic QR codes.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Hands the user back exactly the rows that did not make it, in a format they
|
||||
// can re-upload once they have room. Telling someone what is missing without
|
||||
// giving them the list is only half an apology.
|
||||
const downloadFailedRowsCsv = () => {
|
||||
const header = 'row,title,reason\n';
|
||||
const body = failedRows
|
||||
.map(f => `${f.row},"${f.title.replace(/"/g, '""')}","${f.reason.replace(/"/g, '""')}"`)
|
||||
.join('\n');
|
||||
const blob = new Blob([header + body], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrmaster-missing-rows.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadAllQRCodes = async () => {
|
||||
const zip = new JSZip();
|
||||
|
||||
@@ -370,7 +457,7 @@ export default function BulkCreationPage() {
|
||||
<Button variant="outline" onClick={() => window.location.href = '/dashboard'}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
<Button onClick={() => window.location.href = '/pricing'}>
|
||||
<Button onClick={() => window.location.href = '/upgrade?reason=bulk&from=/bulk-creation'}>
|
||||
Upgrade to Business
|
||||
</Button>
|
||||
</div>
|
||||
@@ -386,6 +473,26 @@ export default function BulkCreationPage() {
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
|
||||
|
||||
{/* Apply a saved design to the whole batch. */}
|
||||
{presets.length > 0 && (
|
||||
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Design preset
|
||||
</label>
|
||||
<Select
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Plain black and white' },
|
||||
...presets.map((p) => ({ value: p.id, label: p.name })),
|
||||
]}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Applied to every code in this upload, so the whole batch matches.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Static / Dynamic Toggle */}
|
||||
<div className="mt-4 flex items-center gap-4 p-4 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<span className="text-sm font-medium text-gray-700">QR Code Type:</span>
|
||||
@@ -772,11 +879,54 @@ export default function BulkCreationPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Generation Complete!</h2>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{failedRows.length > 0
|
||||
? `${generatedQRs.length} of ${generatedQRs.length + failedRows.length} codes created`
|
||||
: 'Generation complete'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Successfully generated {generatedQRs.length} static QR codes
|
||||
{failedRows.length > 0
|
||||
? 'The rows below could not be added. Nothing was silently dropped - here is exactly what is missing.'
|
||||
: `${generatedQRs.length} ${isDynamic ? 'dynamic' : 'static'} QR codes, ready to download.`}
|
||||
</p>
|
||||
|
||||
{failedRows.length > 0 && (
|
||||
<div className="mx-auto mb-8 max-w-3xl rounded-lg border border-amber-200 bg-amber-50 text-left">
|
||||
<div className="border-b border-amber-200 px-5 py-3">
|
||||
<p className="text-sm font-semibold text-amber-900">
|
||||
{failedRows.length} row{failedRows.length === 1 ? '' : 's'} not created
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-amber-800">
|
||||
Check these before you send anything to print.
|
||||
</p>
|
||||
</div>
|
||||
<ul className="max-h-64 divide-y divide-amber-100 overflow-y-auto">
|
||||
{failedRows.slice(0, 50).map((f) => (
|
||||
<li key={`${f.row}-${f.title}`} className="flex items-start justify-between gap-4 px-5 py-2.5">
|
||||
<span className="text-sm text-amber-900">
|
||||
<span className="font-mono text-xs text-amber-700">Row {f.row}</span>{' '}
|
||||
{f.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-amber-700">{f.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{failedRows.length > 50 && (
|
||||
<p className="px-5 py-2 text-xs text-amber-700">
|
||||
and {failedRows.length - 50} more - download the list to see all of them.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 border-t border-amber-200 px-5 py-3">
|
||||
<Button variant="outline" size="sm" onClick={downloadFailedRowsCsv}>
|
||||
Download missing rows as CSV
|
||||
</Button>
|
||||
<Link href="/upgrade?reason=limit&from=/bulk-creation">
|
||||
<Button variant="primary" size="sm">Raise my limit</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
|
||||
{generatedQRs.slice(0, 8).map((qr, index) => (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,15 +7,15 @@ import { StatsGrid } from '@/components/dashboard/StatsGrid';
|
||||
import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||
import { QrCode } from 'lucide-react';
|
||||
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||
import { QrCode } from 'lucide-react';
|
||||
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
||||
|
||||
interface QRCodeData {
|
||||
id: string;
|
||||
@@ -47,8 +47,8 @@ export default function DashboardPage() {
|
||||
conversionRate: 0,
|
||||
uniqueScans: 0,
|
||||
});
|
||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||
const [onboardingState, setOnboardingState] = useState<any>(null);
|
||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||
const [onboardingState, setOnboardingState] = useState<any>(null);
|
||||
|
||||
|
||||
const blogPosts = [
|
||||
@@ -121,11 +121,11 @@ export default function DashboardPage() {
|
||||
// Store in localStorage for consistency
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
|
||||
identifyUser(user.id, {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan || 'FREE',
|
||||
provider: 'google',
|
||||
identifyUser(user.id, {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan || 'FREE',
|
||||
provider: 'google',
|
||||
});
|
||||
|
||||
trackEvent(isNewUser ? 'user_signup' : 'user_login', {
|
||||
@@ -146,35 +146,35 @@ export default function DashboardPage() {
|
||||
}, [searchParams, router]);
|
||||
|
||||
// Check for successful payment and verify session
|
||||
useEffect(() => {
|
||||
const success = searchParams.get('success');
|
||||
const sessionId = searchParams.get('session_id');
|
||||
|
||||
if (success === 'true' && sessionId) {
|
||||
const verifySession = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stripe/verify-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserPlan(data.plan);
|
||||
setUpgradedPlan(data.plan);
|
||||
setShowUpgradeDialog(true);
|
||||
trackEvent('upgrade_completed', {
|
||||
plan: data.plan,
|
||||
source: 'stripe_checkout',
|
||||
});
|
||||
// Remove success parameter from URL
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
console.error('Failed to verify session:', await response.text());
|
||||
}
|
||||
useEffect(() => {
|
||||
const success = searchParams.get('success');
|
||||
const sessionId = searchParams.get('session_id');
|
||||
|
||||
if (success === 'true' && sessionId) {
|
||||
const verifySession = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stripe/verify-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserPlan(data.plan);
|
||||
setUpgradedPlan(data.plan);
|
||||
setShowUpgradeDialog(true);
|
||||
trackEvent('upgrade_completed', {
|
||||
plan: data.plan,
|
||||
source: 'stripe_checkout',
|
||||
});
|
||||
// Remove success parameter from URL
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
console.error('Failed to verify session:', await response.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying session:', error);
|
||||
}
|
||||
@@ -225,19 +225,19 @@ export default function DashboardPage() {
|
||||
setUserPlan(userData.plan || 'FREE');
|
||||
}
|
||||
|
||||
// Fetch analytics data for trends (last 30 days = month comparison)
|
||||
const analyticsResponse = await fetch('/api/analytics/summary?range=30');
|
||||
if (analyticsResponse.ok) {
|
||||
const analytics = await analyticsResponse.json();
|
||||
setAnalyticsData(analytics);
|
||||
}
|
||||
|
||||
const onboardingResponse = await fetch('/api/onboarding');
|
||||
if (onboardingResponse.ok) {
|
||||
const onboardingData = await onboardingResponse.json();
|
||||
setOnboardingState(onboardingData);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fetch analytics data for trends (last 30 days = month comparison)
|
||||
const analyticsResponse = await fetch('/api/analytics/summary?range=30');
|
||||
if (analyticsResponse.ok) {
|
||||
const analytics = await analyticsResponse.json();
|
||||
setAnalyticsData(analytics);
|
||||
}
|
||||
|
||||
const onboardingResponse = await fetch('/api/onboarding');
|
||||
if (onboardingResponse.ok) {
|
||||
const onboardingData = await onboardingResponse.json();
|
||||
setOnboardingState(onboardingData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
setQrCodes([]);
|
||||
setStats({
|
||||
@@ -320,35 +320,35 @@ export default function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Plan Badge */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
{!loading && qrCodes.length === 0
|
||||
? 'Start here — create your first QR code in under 2 minutes'
|
||||
? 'Start here - create your first QR code in under 2 minutes'
|
||||
: t('dashboard.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge className="border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700">
|
||||
{userPlan} Plan
|
||||
</Badge>
|
||||
{userPlan === 'FREE' && (
|
||||
<Link href="/pricing">
|
||||
<Button className="bg-primary-600 text-white hover:bg-primary-700">Upgrade</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge className="border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700">
|
||||
{userPlan} Plan
|
||||
</Badge>
|
||||
{userPlan === 'FREE' && (
|
||||
<Link href="/upgrade?from=/dashboard">
|
||||
<Button className="bg-primary-600 text-white hover:bg-primary-700">Upgrade</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<OnboardingChecklist state={onboardingState} />
|
||||
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
{/* Stats Grid */}
|
||||
<OnboardingChecklist state={onboardingState} />
|
||||
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
trends={{
|
||||
totalScans: analyticsData?.summary.scansTrend,
|
||||
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
|
||||
@@ -357,9 +357,9 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Recent QR Codes */}
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('dashboard.recent_codes')}</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('dashboard.recent_codes')}</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{qrCodes.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -369,12 +369,12 @@ export default function DashboardPage() {
|
||||
>
|
||||
{deletingAll ? 'Deleting...' : 'Delete All'}
|
||||
</Button>
|
||||
)}
|
||||
<Link href="/create">
|
||||
<Button className="bg-primary-600 text-white hover:bg-primary-700">Create New QR Code</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Link href="/create">
|
||||
<Button className="bg-primary-600 text-white hover:bg-primary-700">Create New QR Code</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -394,14 +394,14 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
) : qrCodes.length === 0 ? (
|
||||
<div className="rounded-[24px] border border-dashed border-gray-200 py-16 text-center">
|
||||
<div className="rounded-[24px] border border-dashed border-gray-200 py-16 text-center">
|
||||
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
|
||||
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
|
||||
You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan.
|
||||
</p>
|
||||
You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan.
|
||||
</p>
|
||||
<Link href="/create">
|
||||
<Button className="bg-primary-600 text-white hover:bg-primary-700">Create QR Code — it takes 90 seconds</Button>
|
||||
<Button className="bg-primary-600 text-white hover:bg-primary-700">Create QR Code - it takes 90 seconds</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -470,7 +470,7 @@ export default function DashboardPage() {
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Custom Branding (Colors & Logo)</span>
|
||||
<span>4 Module Shapes, Eye Styles & Your Logo</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
@@ -526,4 +526,4 @@ export default function DashboardPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ export const metadata: Metadata = {
|
||||
title: 'Dashboard | QR Master',
|
||||
description: 'Manage your QR Master dashboard. Create dynamic QR codes, view real-time scan analytics, and configure your account settings in one secure place.',
|
||||
robots: { index: false, follow: false },
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon1.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
shortcut: '/favicon1.png',
|
||||
apple: '/favicon1.png',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ url: '/favicon.ico', sizes: '16x16 32x32', type: 'image/x-icon' },
|
||||
],
|
||||
shortcut: '/favicon.ico',
|
||||
apple: '/logo.svg',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AppGroupLayout({
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import StyledQRCode from '@/components/generator/StyledQRCode';
|
||||
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
ArrowLeft, Edit, ExternalLink, Star, MessageSquare,
|
||||
BarChart3, Copy, Check, Pause, Play
|
||||
BarChart3, Copy, Check, Pause, Play, Download
|
||||
} from 'lucide-react';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
@@ -82,6 +83,55 @@ export default function QRDetailPage() {
|
||||
showToast('Link copied!', 'success');
|
||||
};
|
||||
|
||||
// Download straight from the dashboard. Previously the only way to get a
|
||||
// file back out was to rebuild the code in /create, which also meant the
|
||||
// design had to be recreated from memory.
|
||||
const downloadQR = (format: 'svg' | 'png') => {
|
||||
if (!qrCode) return;
|
||||
const url = `${window.location.origin}/r/${qrCode.slug}`;
|
||||
const style = {
|
||||
foregroundColor: qrCode.style?.foregroundColor,
|
||||
backgroundColor: qrCode.style?.backgroundColor,
|
||||
moduleShape: qrCode.style?.moduleShape,
|
||||
eyeFrameShape: qrCode.style?.eyeFrameShape,
|
||||
eyeBallShape: qrCode.style?.eyeBallShape,
|
||||
gradientMode: qrCode.style?.gradientMode,
|
||||
gradientTo: qrCode.style?.gradientTo,
|
||||
logoUrl: qrCode.style?.imageSettings?.src,
|
||||
logoSize: qrCode.style?.imageSettings?.width,
|
||||
};
|
||||
// 1024px so the PNG is usable in print without a second export step.
|
||||
const svg = renderStyledQRSvg(url, style, format === 'png' ? 1024 : 512);
|
||||
const safeName = (qrCode.title || 'qr-code').replace(/[^a-z0-9]+/gi, '-').toLowerCase();
|
||||
|
||||
if (format === 'svg') {
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const href = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = `${safeName}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(href);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1024;
|
||||
canvas.height = 1024;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0, 1024, 1024);
|
||||
const a = document.createElement('a');
|
||||
a.href = canvas.toDataURL('image/png');
|
||||
a.download = `${safeName}.png`;
|
||||
a.click();
|
||||
};
|
||||
img.onerror = () => showToast('Could not render the PNG. Try the SVG instead.', 'error');
|
||||
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
||||
};
|
||||
|
||||
const toggleStatus = async () => {
|
||||
if (!qrCode) return;
|
||||
const newStatus = qrCode.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
|
||||
@@ -169,11 +219,32 @@ export default function QRDetailPage() {
|
||||
<Card>
|
||||
<CardContent className="p-6 flex flex-col items-center">
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm mb-4">
|
||||
<QRCodeSVG
|
||||
{/* Renders from the saved style, so the code
|
||||
here looks like the one that was designed -
|
||||
shapes, gradient and logo included. It
|
||||
previously drew a plain black grid and
|
||||
ignored everything but the two colours. */}
|
||||
<StyledQRCode
|
||||
value={qrUrl}
|
||||
size={200}
|
||||
fgColor={qrCode.style?.foregroundColor || '#000000'}
|
||||
bgColor={qrCode.style?.backgroundColor || '#FFFFFF'}
|
||||
moduleShape={qrCode.style?.moduleShape || 'square'}
|
||||
eyeFrameShape={qrCode.style?.eyeFrameShape || 'square'}
|
||||
eyeBallShape={qrCode.style?.eyeBallShape || 'square'}
|
||||
gradient={
|
||||
qrCode.style?.gradientMode && qrCode.style.gradientMode !== 'none'
|
||||
? {
|
||||
type: qrCode.style.gradientMode,
|
||||
from: qrCode.style.foregroundColor || '#000000',
|
||||
to: qrCode.style.gradientTo || '#000000',
|
||||
}
|
||||
: null
|
||||
}
|
||||
errorCorrection="H"
|
||||
logoUrl={qrCode.style?.imageSettings?.src}
|
||||
logoScale={(qrCode.style?.imageSettings?.width ?? 24) / 200}
|
||||
margin={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -187,6 +258,14 @@ export default function QRDetailPage() {
|
||||
<ExternalLink className="w-4 h-4 mr-2" /> Open Link
|
||||
</Button>
|
||||
</a>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" onClick={() => downloadQR('png')}>
|
||||
<Download className="w-4 h-4 mr-2" /> PNG
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => downloadQR('svg')}>
|
||||
<Download className="w-4 h-4 mr-2" /> SVG
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -356,7 +356,7 @@ export default function SettingsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => window.location.href = '/pricing'}
|
||||
onClick={() => window.location.href = '/upgrade?from=/settings'}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
@@ -365,7 +365,7 @@ export default function SettingsPage() {
|
||||
|
||||
{plan === 'FREE' && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/pricing'}>
|
||||
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/upgrade?reason=limit&from=/settings'}>
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
227
src/app/(main)/(app)/upgrade/page.tsx
Normal file
227
src/app/(main)/(app)/upgrade/page.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { trackEvent } from '@/components/PostHogProvider';
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
FREE_DYNAMIC_QR_LIMIT,
|
||||
PRO_DYNAMIC_QR_LIMIT,
|
||||
BUSINESS_DYNAMIC_QR_LIMIT,
|
||||
} from '@/lib/plans';
|
||||
|
||||
type PlanKey = 'FREE' | 'PRO' | 'BUSINESS';
|
||||
|
||||
/**
|
||||
* In-app upgrade page.
|
||||
*
|
||||
* /pricing lives in the (marketing) route group, so every upgrade link inside
|
||||
* the app dropped the user out of the product shell and into the public site.
|
||||
* This page sits in the (app) group, keeps the sidebar, and returns the user to
|
||||
* where they came from after checkout.
|
||||
*
|
||||
* Copy note: the plan captions describe the moment each plan stops being enough,
|
||||
* not a feature count. Someone on this page already knows what the product does
|
||||
* - what they are deciding is whether they have crossed a line yet.
|
||||
*/
|
||||
const PLANS: {
|
||||
key: PlanKey;
|
||||
name: string;
|
||||
price: string;
|
||||
period: string;
|
||||
caption: string;
|
||||
features: string[];
|
||||
popular?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: 'FREE',
|
||||
name: 'Free',
|
||||
price: '€0',
|
||||
period: 'forever',
|
||||
caption: 'Enough to prove the idea on one or two placements.',
|
||||
features: [
|
||||
`${FREE_DYNAMIC_QR_LIMIT} active dynamic QR codes`,
|
||||
'Unlimited static codes that never expire',
|
||||
'Basic scan tracking',
|
||||
'Your colors, foreground and background',
|
||||
'SVG and PNG download',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'PRO',
|
||||
name: 'Pro',
|
||||
price: '€9',
|
||||
period: 'per month',
|
||||
popular: true,
|
||||
caption: 'When one campaign is no longer the only campaign.',
|
||||
features: [
|
||||
`${PRO_DYNAMIC_QR_LIMIT} dynamic QR codes`,
|
||||
'Scan data by device, location and time',
|
||||
'4 module shapes and custom eye styles',
|
||||
'Your logo in the centre of the code',
|
||||
'Everything in Free',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'BUSINESS',
|
||||
name: 'Business',
|
||||
price: '€29',
|
||||
period: 'per month',
|
||||
caption: 'When codes are produced in batches, not one at a time.',
|
||||
features: [
|
||||
`${BUSINESS_DYNAMIC_QR_LIMIT} dynamic QR codes`,
|
||||
'Bulk creation: 1,000 static or 500 dynamic per upload',
|
||||
'Full designer: 11 module shapes and colour gradients',
|
||||
'Saved design presets, applied to a whole bulk upload',
|
||||
'Priority email support',
|
||||
'Everything in Pro',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const REASON_HEADLINES: Record<string, string> = {
|
||||
limit: 'You are out of dynamic code slots.',
|
||||
shapes: 'Module shapes start on Pro.',
|
||||
logo: 'Your logo belongs inside the code.',
|
||||
bulk: 'Bulk creation is a Business feature.',
|
||||
analytics: 'You are seeing totals, not sources.',
|
||||
};
|
||||
|
||||
export default function UpgradePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [currentPlan, setCurrentPlan] = useState<PlanKey>('FREE');
|
||||
const [loadingPlan, setLoadingPlan] = useState<PlanKey | null>(null);
|
||||
|
||||
const reason = searchParams.get('reason');
|
||||
const returnTo = searchParams.get('from');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/user/plan')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => d?.plan && setCurrentPlan(d.plan))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('canceled') === 'true') {
|
||||
showToast('Checkout canceled. Nothing was charged.', 'info');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleUpgrade = async (plan: PlanKey) => {
|
||||
if (plan === 'FREE') return;
|
||||
setLoadingPlan(plan);
|
||||
trackEvent('upgrade_clicked', { plan, source: 'in_app_upgrade', reason });
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/stripe/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
billingInterval: 'month',
|
||||
returnPath: returnTo && returnTo.startsWith('/') ? returnTo : '/dashboard',
|
||||
}),
|
||||
});
|
||||
|
||||
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) {
|
||||
showToast(err?.message || 'Could not start checkout. Please try again.', 'error');
|
||||
setLoadingPlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-2xl">
|
||||
<h1 className="text-3xl font-bold text-slate-900">
|
||||
{reason && REASON_HEADLINES[reason]
|
||||
? REASON_HEADLINES[reason]
|
||||
: 'Pick the plan that matches what you are running'}
|
||||
</h1>
|
||||
<p className="mt-3 text-base leading-relaxed text-slate-600">
|
||||
Every plan keeps your static codes working forever, and nothing you have
|
||||
already printed stops resolving if you change plans. Cancel any time from
|
||||
Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const isCurrent = plan.key === currentPlan;
|
||||
return (
|
||||
<Card
|
||||
key={plan.key}
|
||||
className={plan.popular ? 'border-2 border-primary-500' : undefined}
|
||||
>
|
||||
<CardContent className="flex h-full flex-col p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-900">{plan.name}</h2>
|
||||
{plan.popular && <Badge variant="info">Most popular</Badge>}
|
||||
{isCurrent && <Badge variant="success">Current plan</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-slate-900">{plan.price}</span>
|
||||
<span className="text-sm text-slate-500">{plan.period}</span>
|
||||
</div>
|
||||
<p className="mb-6 text-sm text-slate-600">{plan.caption}</p>
|
||||
|
||||
<ul className="mb-8 space-y-3">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm text-slate-700">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto">
|
||||
{isCurrent ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Current plan
|
||||
</Button>
|
||||
) : plan.key === 'FREE' ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Included
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={plan.popular ? 'primary' : 'secondary'}
|
||||
className="w-full"
|
||||
disabled={loadingPlan !== null}
|
||||
onClick={() => handleUpgrade(plan.key)}
|
||||
>
|
||||
{loadingPlan === plan.key ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
|
||||
</span>
|
||||
) : (
|
||||
`Upgrade to ${plan.name}`
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm text-slate-500">
|
||||
Prices exclude VAT where applicable. Payments run through Stripe - QR Master
|
||||
never sees your card details.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user