feat: implement pricing strategy, subscription tiers, and core infrastructure for QR code management
This commit is contained in:
@@ -121,6 +121,7 @@ enum ContentType {
|
|||||||
APP
|
APP
|
||||||
COUPON
|
COUPON
|
||||||
FEEDBACK
|
FEEDBACK
|
||||||
|
BARCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum QRStatus {
|
enum QRStatus {
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ interface BulkQRData {
|
|||||||
|
|
||||||
interface GeneratedQR {
|
interface GeneratedQR {
|
||||||
title: string;
|
title: string;
|
||||||
content: string; // Original URL
|
content: string;
|
||||||
svg: string; // SVG markup
|
svg: string;
|
||||||
|
slug?: string;
|
||||||
|
redirectUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BulkCreationPage() {
|
export default function BulkCreationPage() {
|
||||||
@@ -35,16 +37,25 @@ export default function BulkCreationPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]);
|
const [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]);
|
||||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||||
|
const [isDynamic, setIsDynamic] = useState(false);
|
||||||
|
const [remainingDynamic, setRemainingDynamic] = useState(0);
|
||||||
|
|
||||||
// Check user plan on mount
|
// Check user plan and dynamic quota on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const checkPlan = async () => {
|
const checkPlan = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/plan');
|
const [planRes, statsRes] = await Promise.all([
|
||||||
if (response.ok) {
|
fetch('/api/user/plan'),
|
||||||
const data = await response.json();
|
fetch('/api/user/stats'),
|
||||||
|
]);
|
||||||
|
if (planRes.ok) {
|
||||||
|
const data = await planRes.json();
|
||||||
setUserPlan(data.plan || 'FREE');
|
setUserPlan(data.plan || 'FREE');
|
||||||
}
|
}
|
||||||
|
if (statsRes.ok) {
|
||||||
|
const stats = await statsRes.json();
|
||||||
|
setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking plan:', error);
|
console.error('Error checking plan:', error);
|
||||||
}
|
}
|
||||||
@@ -196,6 +207,58 @@ export default function BulkCreationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateDynamicQRCodes = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
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');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length > remainingDynamic) {
|
||||||
|
showToast(`Nur ${remainingDynamic} dynamische Codes verfügbar. Es werden nur die ersten ${remainingDynamic} Zeilen verarbeitet.`, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const results: GeneratedQR[] = [];
|
||||||
|
|
||||||
|
for (const row of toProcess) {
|
||||||
|
const title = String(row[mapping.title as keyof typeof row] || 'Untitled');
|
||||||
|
const url = String(row[mapping.content as keyof typeof row] || 'https://example.com');
|
||||||
|
|
||||||
|
const res = await fetchWithCsrf('/api/qrs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
contentType: 'URL',
|
||||||
|
content: { url },
|
||||||
|
isStatic: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
results.push({ title, content: url, svg, slug: qr.slug, redirectUrl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeneratedQRs(results);
|
||||||
|
setRemainingDynamic(prev => Math.max(0, prev - results.length));
|
||||||
|
setStep('complete');
|
||||||
|
showToast(`${results.length} dynamische QR-Codes erstellt!`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dynamic QR generation error:', error);
|
||||||
|
showToast('Fehler beim Erstellen der dynamischen QR-Codes', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const downloadAllQRCodes = async () => {
|
const downloadAllQRCodes = async () => {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
@@ -204,6 +267,18 @@ export default function BulkCreationPage() {
|
|||||||
zip.file(fileName, qr.svg);
|
zip.file(fileName, qr.svg);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add metadata CSV for dynamic QR codes
|
||||||
|
const hasDynamic = generatedQRs.some(qr => qr.slug);
|
||||||
|
if (hasDynamic) {
|
||||||
|
const csvRows = ['title,original_url,redirect_url,slug'];
|
||||||
|
generatedQRs.forEach(qr => {
|
||||||
|
if (qr.slug) {
|
||||||
|
csvRows.push(`"${qr.title}","${qr.content}","${qr.redirectUrl}","${qr.slug}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
zip.file('metadata.csv', csvRows.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
const blob = await zip.generateAsync({ type: 'blob' });
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
saveAs(blob, 'qr-codes-bulk.zip');
|
saveAs(blob, 'qr-codes-bulk.zip');
|
||||||
showToast('Download started!', 'success');
|
showToast('Download started!', 'success');
|
||||||
@@ -274,8 +349,8 @@ export default function BulkCreationPage() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show upgrade prompt if not Business plan
|
// Show upgrade prompt if not Business or Enterprise plan
|
||||||
if (userPlan !== 'BUSINESS') {
|
if (userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE') {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<Card className="mt-12">
|
<Card className="mt-12">
|
||||||
@@ -309,6 +384,39 @@ export default function BulkCreationPage() {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
|
||||||
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
|
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={!isDynamic}
|
||||||
|
onChange={() => setIsDynamic(false)}
|
||||||
|
className="accent-primary-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">Static</span>
|
||||||
|
<span className="text-xs text-gray-500">(download only, no tracking)</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex items-center gap-2 ${userPlan === 'BUSINESS' || userPlan === 'ENTERPRISE' ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={isDynamic}
|
||||||
|
onChange={() => setIsDynamic(true)}
|
||||||
|
disabled={userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE'}
|
||||||
|
className="accent-primary-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">Dynamic</span>
|
||||||
|
{isDynamic && remainingDynamic > 0 && (
|
||||||
|
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">
|
||||||
|
{remainingDynamic} verbleibend
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE') && (
|
||||||
|
<span className="text-xs text-amber-600">(Business Plan erforderlich)</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template Warning Banner */}
|
{/* Template Warning Banner */}
|
||||||
@@ -641,8 +749,13 @@ export default function BulkCreationPage() {
|
|||||||
<Button variant="outline" onClick={() => setStep('upload')}>
|
<Button variant="outline" onClick={() => setStep('upload')}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={generateStaticQRCodes} loading={loading}>
|
<Button
|
||||||
Generate {data.length} Static QR Codes
|
onClick={isDynamic ? generateDynamicQRCodes : generateStaticQRCodes}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{isDynamic
|
||||||
|
? `Generate ${Math.min(data.length, remainingDynamic)} Dynamic QR Codes`
|
||||||
|
: `Generate ${data.length} Static QR Codes`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import { useTranslation } from '@/hooks/useTranslation';
|
|||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import {
|
import {
|
||||||
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload
|
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import Barcode from 'react-barcode';
|
||||||
|
|
||||||
// Tooltip component for form field help
|
// Tooltip component for form field help
|
||||||
const Tooltip = ({ text }: { text: string }) => (
|
const Tooltip = ({ text }: { text: string }) => (
|
||||||
@@ -140,6 +141,7 @@ export default function CreatePage() {
|
|||||||
{ value: 'APP', label: 'App Download', icon: Smartphone },
|
{ value: 'APP', label: 'App Download', icon: Smartphone },
|
||||||
{ value: 'COUPON', label: 'Coupon / Discount', icon: Ticket },
|
{ value: 'COUPON', label: 'Coupon / Discount', icon: Ticket },
|
||||||
{ value: 'FEEDBACK', label: 'Feedback / Review', icon: Star },
|
{ value: 'FEEDBACK', label: 'Feedback / Review', icon: Star },
|
||||||
|
{ value: 'BARCODE', label: 'Barcode', icon: BarcodeIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get QR content based on content type
|
// Get QR content based on content type
|
||||||
@@ -170,6 +172,8 @@ export default function CreatePage() {
|
|||||||
return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`;
|
return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`;
|
||||||
case 'FEEDBACK':
|
case 'FEEDBACK':
|
||||||
return content.feedbackUrl || 'https://example.com/feedback';
|
return content.feedbackUrl || 'https://example.com/feedback';
|
||||||
|
case 'BARCODE':
|
||||||
|
return content.value || '123456789';
|
||||||
default:
|
default:
|
||||||
return 'https://example.com';
|
return 'https://example.com';
|
||||||
}
|
}
|
||||||
@@ -642,6 +646,68 @@ export default function CreatePage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
case 'BARCODE':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isDynamic ? (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3 text-sm text-blue-800">
|
||||||
|
<strong>How dynamic barcodes work:</strong> The barcode encodes a short redirect URL
|
||||||
|
(e.g. <span className="font-mono text-xs">qrmaster.net/r/…</span>). When scanned with a
|
||||||
|
smartphone camera, it opens the browser and redirects to your destination — which you
|
||||||
|
can update anytime. Works with smartphone cameras, not POS laser scanners.
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Destination URL"
|
||||||
|
value={content.url || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Barcode Format</label>
|
||||||
|
<select
|
||||||
|
value={['CODE128', 'CODE39'].includes(content.format) ? content.format : 'CODE128'}
|
||||||
|
onChange={(e) => setContent({ ...content, format: e.target.value })}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="CODE128">CODE128 — General purpose (recommended)</option>
|
||||||
|
<option value="CODE39">CODE39 — Industrial / logistics</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Only URL-capable formats available. EAN-13, UPC, and ITF-14 encode numbers only and cannot embed a redirect URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Barcode Value"
|
||||||
|
value={content.value || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, value: e.target.value })}
|
||||||
|
placeholder="123456789012"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Barcode Format</label>
|
||||||
|
<select
|
||||||
|
value={content.format || 'CODE128'}
|
||||||
|
onChange={(e) => setContent({ ...content, format: e.target.value })}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="CODE128">CODE128 — General purpose (recommended)</option>
|
||||||
|
<option value="EAN13">EAN-13 — Retail products (international)</option>
|
||||||
|
<option value="UPC">UPC — Retail products (USA/Canada)</option>
|
||||||
|
<option value="CODE39">CODE39 — Industrial / logistics</option>
|
||||||
|
<option value="ITF14">ITF-14 — Shipping containers</option>
|
||||||
|
<option value="MSI">MSI — Shelf labeling / inventory</option>
|
||||||
|
<option value="pharmacode">Pharmacode — Pharmaceutical packaging</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -992,7 +1058,25 @@ export default function CreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{qrContent ? (
|
{contentType === 'BARCODE' ? (
|
||||||
|
qrContent ? (
|
||||||
|
<div className="p-2 bg-white">
|
||||||
|
<Barcode
|
||||||
|
value={qrContent}
|
||||||
|
format={content.format || 'CODE128'}
|
||||||
|
lineColor={foregroundColor}
|
||||||
|
background={backgroundColor}
|
||||||
|
width={2}
|
||||||
|
height={100}
|
||||||
|
displayValue={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
|
||||||
|
Enter barcode value
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : qrContent ? (
|
||||||
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
||||||
<QRCodeSVG
|
<QRCodeSVG
|
||||||
value={qrContent}
|
value={qrContent}
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ export default function SettingsPage() {
|
|||||||
return { dynamic: 50, price: '€9', period: 'per month' };
|
return { dynamic: 50, price: '€9', period: 'per month' };
|
||||||
case 'BUSINESS':
|
case 'BUSINESS':
|
||||||
return { dynamic: 500, price: '€29', period: 'per month' };
|
return { dynamic: 500, price: '€29', period: 'per month' };
|
||||||
|
case 'ENTERPRISE':
|
||||||
|
return { dynamic: 99999, price: 'Custom', period: 'per month' };
|
||||||
default:
|
default:
|
||||||
return { dynamic: 3, price: '€0', period: 'forever' };
|
return { dynamic: 3, price: '€0', period: 'forever' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -112,14 +113,37 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<div className="space-y-1">
|
||||||
label="Password"
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
|
||||||
type="password"
|
<div className="relative">
|
||||||
value={password}
|
<input
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
id="password"
|
||||||
placeholder="••••••••"
|
type={showPassword ? 'text' : 'password'}
|
||||||
required
|
value={password}
|
||||||
/>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
className="flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 pr-10 text-sm placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export default function SignupClient() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -126,23 +128,69 @@ export default function SignupClient() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<div className="space-y-1">
|
||||||
label="Password"
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
|
||||||
type="password"
|
<div className="relative">
|
||||||
value={password}
|
<input
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
id="password"
|
||||||
placeholder="••••••••"
|
type={showPassword ? 'text' : 'password'}
|
||||||
required
|
value={password}
|
||||||
/>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
className="flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 pr-10 text-sm placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<div className="space-y-1">
|
||||||
label="Confirm Password"
|
<label htmlFor="confirm-password" className="block text-sm font-medium text-gray-700">Confirm Password</label>
|
||||||
type="password"
|
<div className="relative">
|
||||||
value={confirmPassword}
|
<input
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
id="confirm-password"
|
||||||
placeholder="••••••••"
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
required
|
value={confirmPassword}
|
||||||
/>
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
className="flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 pr-10 text-sm placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
Create Account
|
Create Account
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export default function PricingPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||||
const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null);
|
const [currentInterval, setCurrentInterval] = useState<
|
||||||
|
'month' | 'year' | null
|
||||||
|
>(null);
|
||||||
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,19 +97,29 @@ export default function PricingPage() {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error canceling subscription:', error);
|
console.error('Error canceling subscription:', error);
|
||||||
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
showToast(
|
||||||
|
error.message || 'Failed to downgrade. Please try again.',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to check if this is the user's exact current plan (plan + interval)
|
// Helper function to check if this is the user's exact current plan (plan + interval)
|
||||||
const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => {
|
const isCurrentPlanWithInterval = (
|
||||||
|
planType: string,
|
||||||
|
interval: 'month' | 'year'
|
||||||
|
) => {
|
||||||
return currentPlan === planType && currentInterval === interval;
|
return currentPlan === planType && currentInterval === interval;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to check if user has this plan but different interval
|
// Helper function to check if user has this plan but different interval
|
||||||
const hasPlanDifferentInterval = (planType: string) => {
|
const hasPlanDifferentInterval = (planType: string) => {
|
||||||
return currentPlan === planType && currentInterval && currentInterval !== billingPeriod;
|
return (
|
||||||
|
currentPlan === planType &&
|
||||||
|
currentInterval &&
|
||||||
|
currentInterval !== billingPeriod
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
||||||
@@ -178,6 +190,24 @@ export default function PricingPage() {
|
|||||||
popular: false,
|
popular: false,
|
||||||
onUpgrade: () => handleUpgrade('BUSINESS'),
|
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'enterprise',
|
||||||
|
name: 'Enterprise',
|
||||||
|
price: 'Custom',
|
||||||
|
period: '',
|
||||||
|
showDiscount: false,
|
||||||
|
features: [
|
||||||
|
'∞ dynamic QR codes',
|
||||||
|
'Unlimited static QR codes',
|
||||||
|
'Everything from Business',
|
||||||
|
'Dedicated Account Manager',
|
||||||
|
],
|
||||||
|
buttonText: 'Contact Us',
|
||||||
|
buttonVariant: 'outline' as const,
|
||||||
|
disabled: false,
|
||||||
|
popular: false,
|
||||||
|
onUpgrade: () => (window.location.href = 'mailto:timo@qrmaster.net'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -195,11 +225,13 @@ export default function PricingPage() {
|
|||||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto">
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<Card
|
<Card
|
||||||
key={plan.key}
|
key={plan.key}
|
||||||
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
className={
|
||||||
|
plan.popular ? 'border-primary-500 shadow-xl relative' : ''
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{plan.popular && (
|
{plan.popular && (
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||||
@@ -210,17 +242,11 @@ export default function PricingPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<CardHeader className="text-center pb-8">
|
<CardHeader className="text-center pb-8">
|
||||||
<CardTitle className="text-2xl mb-4">
|
<CardTitle className="text-2xl mb-4">{plan.name}</CardTitle>
|
||||||
{plan.name}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="flex items-baseline justify-center">
|
<div className="flex items-baseline justify-center">
|
||||||
<span className="text-4xl font-bold">
|
<span className="text-4xl font-bold">{plan.price}</span>
|
||||||
{plan.price}
|
<span className="text-gray-600 ml-2">{plan.period}</span>
|
||||||
</span>
|
|
||||||
<span className="text-gray-600 ml-2">
|
|
||||||
{plan.period}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{plan.showDiscount && (
|
{plan.showDiscount && (
|
||||||
<Badge variant="success" className="mt-2">
|
<Badge variant="success" className="mt-2">
|
||||||
@@ -234,8 +260,16 @@ export default function PricingPage() {
|
|||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{plan.features.map((feature: string, index: number) => (
|
{plan.features.map((feature: string, index: number) => (
|
||||||
<li key={index} className="flex items-start space-x-3">
|
<li key={index} className="flex items-start space-x-3">
|
||||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-gray-700">{feature}</span>
|
<span className="text-gray-700">{feature}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -247,9 +281,15 @@ export default function PricingPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||||
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
onClick={
|
||||||
|
plan.key === 'free'
|
||||||
|
? (plan as any).onDowngrade
|
||||||
|
: (plan as any).onUpgrade
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
{loading === plan.key.toUpperCase()
|
||||||
|
? 'Processing...'
|
||||||
|
: plan.buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -261,7 +301,13 @@ export default function PricingPage() {
|
|||||||
All plans include unlimited static QR codes and basic customization.
|
All plans include unlimited static QR codes and basic customization.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-600 mt-2">
|
<p className="text-gray-600 mt-2">
|
||||||
Need help choosing? <ObfuscatedMailto email="support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</ObfuscatedMailto>
|
Need help choosing?{' '}
|
||||||
|
<ObfuscatedMailto
|
||||||
|
email="support@qrmaster.net"
|
||||||
|
className="text-primary-600 hover:text-primary-700 underline"
|
||||||
|
>
|
||||||
|
Contact our team
|
||||||
|
</ObfuscatedMailto>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const PLAN_LIMITS = {
|
|||||||
FREE: 3,
|
FREE: 3,
|
||||||
PRO: 50,
|
PRO: 50,
|
||||||
BUSINESS: 500,
|
BUSINESS: 500,
|
||||||
|
ENTERPRISE: 99999,
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST /api/qrs - Create a new QR code
|
// POST /api/qrs - Create a new QR code
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export async function GET(request: NextRequest) {
|
|||||||
dynamicLimit = 50;
|
dynamicLimit = 50;
|
||||||
} else if (user.plan === 'BUSINESS') {
|
} else if (user.plan === 'BUSINESS') {
|
||||||
dynamicLimit = 500;
|
dynamicLimit = 500;
|
||||||
|
} else if ((user.plan as string) === 'ENTERPRISE') {
|
||||||
|
dynamicLimit = 99999;
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ export async function GET(
|
|||||||
const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||||
destination = `${baseUrlFeedback}/feedback/${slug}`;
|
destination = `${baseUrlFeedback}/feedback/${slug}`;
|
||||||
break;
|
break;
|
||||||
|
case 'BARCODE':
|
||||||
|
// Dynamic barcode redirects to its stored URL
|
||||||
|
destination = ensureAbsoluteUrl(content.url || content.value || 'https://example.com');
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
destination = 'https://example.com';
|
destination = 'https://example.com';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/dynamic-barcode-generator`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/bulk-qr-code-generator`,
|
url: `${baseUrl}/bulk-qr-code-generator`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
|||||||
key: 'business',
|
key: 'business',
|
||||||
popular: false,
|
popular: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'enterprise',
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,16 +47,14 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
|||||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||||
{t.pricing.title}
|
{t.pricing.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600">
|
<p className="text-xl text-gray-600">{t.pricing.subtitle}</p>
|
||||||
{t.pricing.subtitle}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex justify-center mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto">
|
||||||
{plans.map((plan, index) => (
|
{plans.map((plan, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={plan.key}
|
key={plan.key}
|
||||||
@@ -63,10 +65,11 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
|||||||
className="h-full"
|
className="h-full"
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`h-full flex flex-col ${plan.popular
|
className={`h-full flex flex-col ${
|
||||||
|
plan.popular
|
||||||
? 'border-primary-500 shadow-xl relative scale-105 z-10'
|
? 'border-primary-500 shadow-xl relative scale-105 z-10'
|
||||||
: 'border-gray-200 hover:border-gray-300 hover:shadow-lg transition-all'
|
: 'border-gray-200 hover:border-gray-300 hover:shadow-lg transition-all'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{plan.popular && (
|
{plan.popular && (
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 w-full text-center">
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 w-full text-center">
|
||||||
@@ -83,7 +86,7 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="flex items-baseline justify-center">
|
<div className="flex items-baseline justify-center">
|
||||||
<span className="text-4xl font-bold">
|
<span className="text-4xl font-bold">
|
||||||
{plan.key === 'free'
|
{plan.key === 'free' || plan.key === 'enterprise'
|
||||||
? t.pricing[plan.key].price
|
? t.pricing[plan.key].price
|
||||||
: billingPeriod === 'month'
|
: billingPeriod === 'month'
|
||||||
? t.pricing[plan.key].price
|
? t.pricing[plan.key].price
|
||||||
@@ -92,43 +95,63 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
|||||||
: '€290'}
|
: '€290'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-600 ml-2">
|
<span className="text-gray-600 ml-2">
|
||||||
{plan.key === 'free'
|
{plan.key === 'free' || plan.key === 'enterprise'
|
||||||
? t.pricing[plan.key].period
|
? t.pricing[plan.key].period
|
||||||
: billingPeriod === 'month'
|
: billingPeriod === 'month'
|
||||||
? t.pricing[plan.key].period
|
? t.pricing[plan.key].period
|
||||||
: 'per year'}
|
: 'per year'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{billingPeriod === 'year' && plan.key !== 'free' && (
|
{billingPeriod === 'year' &&
|
||||||
<Badge variant="success" className="mt-2">
|
plan.key !== 'free' &&
|
||||||
Save 16%
|
plan.key !== 'enterprise' && (
|
||||||
</Badge>
|
<Badge variant="success" className="mt-2">
|
||||||
)}
|
Save 16%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-8 flex-1 flex flex-col">
|
<CardContent className="space-y-8 flex-1 flex flex-col">
|
||||||
<ul className="space-y-3 flex-1">
|
<ul className="space-y-3 flex-1">
|
||||||
{t.pricing[plan.key].features.map((feature: string, index: number) => (
|
{t.pricing[plan.key].features.map(
|
||||||
<li key={index} className="flex items-start space-x-3">
|
(feature: string, index: number) => (
|
||||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
<li key={index} className="flex items-start space-x-3">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<svg
|
||||||
</svg>
|
className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5"
|
||||||
<span className="text-gray-700">{feature}</span>
|
fill="currentColor"
|
||||||
</li>
|
viewBox="0 0 20 20"
|
||||||
))}
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-700">{feature}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="mt-8 pt-8 border-t border-gray-100">
|
<div className="mt-8 pt-8 border-t border-gray-100">
|
||||||
<Link href="/signup">
|
{plan.key === 'enterprise' ? (
|
||||||
<Button
|
<Link href="mailto:timo@qrmaster.net">
|
||||||
variant={plan.popular ? 'primary' : 'outline'}
|
<Button variant="outline" className="w-full" size="lg">
|
||||||
className="w-full"
|
{t.pricing[plan.key].contact || 'Contact Us'}
|
||||||
size="lg"
|
</Button>
|
||||||
>
|
</Link>
|
||||||
Get Started
|
) : (
|
||||||
</Button>
|
<Link href="/signup">
|
||||||
</Link>
|
<Button
|
||||||
|
variant={plan.popular ? 'primary' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export function Footer({ variant = 'marketing', t }: FooterProps) {
|
|||||||
<li><Link href="/faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>{translations.all_questions}</Link></li>
|
<li><Link href="/faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>{translations.all_questions}</Link></li>
|
||||||
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>{translations.all_articles}</Link></li>
|
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>{translations.all_articles}</Link></li>
|
||||||
<li><Link href="/bulk-qr-code-generator" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Bulk QR Generator</Link></li>
|
<li><Link href="/bulk-qr-code-generator" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Bulk QR Generator</Link></li>
|
||||||
|
<li><Link href="/dynamic-barcode-generator" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Dynamic Barcode Generator</Link></li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<Link href="/qr-code-for" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>
|
<Link href="/qr-code-for" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>
|
||||||
|
|||||||
@@ -178,6 +178,19 @@
|
|||||||
"Prioritäts-E-Mail-Support",
|
"Prioritäts-E-Mail-Support",
|
||||||
"Erweiterte Tracking & Insights"
|
"Erweiterte Tracking & Insights"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"enterprise": {
|
||||||
|
"title": "Enterprise",
|
||||||
|
"name": "Enterprise",
|
||||||
|
"price": "Individuell",
|
||||||
|
"period": "",
|
||||||
|
"features": [
|
||||||
|
"∞ dynamische QR-Codes",
|
||||||
|
"Unbegrenzte statische QR-Codes",
|
||||||
|
"Alles aus Business",
|
||||||
|
"Eigener Account Manager"
|
||||||
|
],
|
||||||
|
"contact": "Kontakt aufnehmen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"faq": {
|
"faq": {
|
||||||
|
|||||||
@@ -176,6 +176,19 @@
|
|||||||
"Priority email support",
|
"Priority email support",
|
||||||
"Advanced tracking & insights"
|
"Advanced tracking & insights"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"enterprise": {
|
||||||
|
"title": "Enterprise",
|
||||||
|
"name": "Enterprise",
|
||||||
|
"price": "Custom",
|
||||||
|
"period": "",
|
||||||
|
"features": [
|
||||||
|
"∞ dynamic QR codes",
|
||||||
|
"Unlimited static QR codes",
|
||||||
|
"Everything from Business",
|
||||||
|
"Dedicated Account Manager"
|
||||||
|
],
|
||||||
|
"contact": "Contact Us"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"faq": {
|
"faq": {
|
||||||
@@ -346,20 +359,12 @@
|
|||||||
"airtable": {
|
"airtable": {
|
||||||
"title": "Airtable",
|
"title": "Airtable",
|
||||||
"description": "Sync QR codes with your Airtable bases",
|
"description": "Sync QR codes with your Airtable bases",
|
||||||
"features": [
|
"features": ["Two-way sync", "Custom field mapping", "Real-time updates"]
|
||||||
"Two-way sync",
|
|
||||||
"Custom field mapping",
|
|
||||||
"Real-time updates"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"sheets": {
|
"sheets": {
|
||||||
"title": "Google Sheets",
|
"title": "Google Sheets",
|
||||||
"description": "Export data to Google Sheets automatically",
|
"description": "Export data to Google Sheets automatically",
|
||||||
"features": [
|
"features": ["Automated exports", "Custom templates", "Scheduled updates"]
|
||||||
"Automated exports",
|
|
||||||
"Custom templates",
|
|
||||||
"Scheduled updates"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"activate": "Activate & Configure"
|
"activate": "Activate & Configure"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function middleware(req: NextRequest) {
|
|||||||
// '/guide', // Redirected to /learn/*
|
// '/guide', // Redirected to /learn/*
|
||||||
'/qr-code-erstellen',
|
'/qr-code-erstellen',
|
||||||
'/dynamic-qr-code-generator',
|
'/dynamic-qr-code-generator',
|
||||||
|
'/dynamic-barcode-generator',
|
||||||
'/bulk-qr-code-generator',
|
'/bulk-qr-code-generator',
|
||||||
'/qr-code-tracking',
|
'/qr-code-tracking',
|
||||||
'/qr-code-analytics',
|
'/qr-code-analytics',
|
||||||
|
|||||||
Reference in New Issue
Block a user