Copy overhaul + qr designs

This commit is contained in:
2026-07-27 17:54:59 +02:00
parent 033bc7e29d
commit 70d97aa970
144 changed files with 23107 additions and 1699 deletions

View File

@@ -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>
);
}
}

View File

@@ -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

View File

@@ -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 &amp; 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>
);
}
}

View File

@@ -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({

View File

@@ -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>

View File

@@ -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>

View 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>
);
}