search console SEO ableitungen
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,392 +1,392 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Dialog } from '@/components/ui/Dialog';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface Integration {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
status: 'active' | 'inactive' | 'coming_soon';
|
||||
category: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null);
|
||||
const [showSetupDialog, setShowSetupDialog] = useState(false);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [webhookUrl, setWebhookUrl] = useState('');
|
||||
|
||||
const integrations: Integration[] = [
|
||||
{
|
||||
id: 'zapier',
|
||||
name: 'Zapier',
|
||||
description: 'Connect QR Master with 5,000+ apps',
|
||||
icon: '⚡',
|
||||
status: 'active',
|
||||
category: 'Automation',
|
||||
features: [
|
||||
'Trigger actions when QR codes are scanned',
|
||||
'Create QR codes from other apps',
|
||||
'Update QR destinations automatically',
|
||||
'Sync analytics to spreadsheets',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'airtable',
|
||||
name: 'Airtable',
|
||||
description: 'Sync QR codes with your Airtable bases',
|
||||
icon: '📊',
|
||||
status: 'inactive',
|
||||
category: 'Database',
|
||||
features: [
|
||||
'Two-way sync with Airtable',
|
||||
'Bulk import from bases',
|
||||
'Auto-update QR content',
|
||||
'Analytics dashboard integration',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'google-sheets',
|
||||
name: 'Google Sheets',
|
||||
description: 'Manage QR codes from spreadsheets',
|
||||
icon: '📈',
|
||||
status: 'inactive',
|
||||
category: 'Spreadsheet',
|
||||
features: [
|
||||
'Import QR codes from sheets',
|
||||
'Export analytics data',
|
||||
'Real-time sync',
|
||||
'Collaborative QR management',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Get QR scan notifications in Slack',
|
||||
icon: '💬',
|
||||
status: 'coming_soon',
|
||||
category: 'Communication',
|
||||
features: [
|
||||
'Real-time scan notifications',
|
||||
'Daily analytics summaries',
|
||||
'Team collaboration',
|
||||
'Custom alert rules',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhooks',
|
||||
description: 'Send data to any URL',
|
||||
icon: '🔗',
|
||||
status: 'active',
|
||||
category: 'Developer',
|
||||
features: [
|
||||
'Custom webhook endpoints',
|
||||
'Real-time event streaming',
|
||||
'Retry logic',
|
||||
'Event filtering',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
name: 'REST API',
|
||||
description: 'Full programmatic access',
|
||||
icon: '🔧',
|
||||
status: 'active',
|
||||
category: 'Developer',
|
||||
features: [
|
||||
'Complete CRUD operations',
|
||||
'Bulk operations',
|
||||
'Analytics API',
|
||||
'Rate limiting: 1000 req/hour',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const stats = {
|
||||
totalQRCodes: 234,
|
||||
activeIntegrations: 2,
|
||||
syncStatus: 'Synced',
|
||||
availableServices: 6,
|
||||
};
|
||||
|
||||
const handleActivate = (integration: Integration) => {
|
||||
setSelectedIntegration(integration);
|
||||
setShowSetupDialog(true);
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
// Simulate API test
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
alert('Connection successful!');
|
||||
};
|
||||
|
||||
const handleSaveIntegration = () => {
|
||||
setShowSetupDialog(false);
|
||||
// Update integration status
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('integrations.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">{t('integrations.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">QR Codes Total</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalQRCodes}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Active Integrations</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.activeIntegrations}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-success-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-success-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Sync Status</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.syncStatus}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-info-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-info-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Available Services</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.availableServices}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Integration Cards */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{integrations.map((integration) => (
|
||||
<Card key={integration.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-3xl">{integration.icon}</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{integration.name}</CardTitle>
|
||||
<Badge
|
||||
variant={
|
||||
integration.status === 'active' ? 'success' :
|
||||
integration.status === 'coming_soon' ? 'warning' :
|
||||
'default'
|
||||
}
|
||||
className="mt-1"
|
||||
>
|
||||
{integration.status === 'active' ? 'Active' :
|
||||
integration.status === 'coming_soon' ? 'Coming Soon' :
|
||||
'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-4">{integration.description}</p>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{integration.features.slice(0, 3).map((feature, index) => (
|
||||
<div key={index} className="flex items-start space-x-2">
|
||||
<svg className="w-4 h-4 text-success-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{integration.status === 'active' ? (
|
||||
<Button variant="outline" className="w-full">
|
||||
Configure
|
||||
</Button>
|
||||
) : integration.status === 'coming_soon' ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Coming Soon
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={() => handleActivate(integration)}>
|
||||
Activate & Configure
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Setup Dialog */}
|
||||
{showSetupDialog && selectedIntegration && (
|
||||
<Dialog
|
||||
open={showSetupDialog}
|
||||
onOpenChange={setShowSetupDialog}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
|
||||
<h2 className="text-lg font-semibold mb-4">Setup {selectedIntegration.name}</h2>
|
||||
<div className="space-y-4">
|
||||
{selectedIntegration.id === 'zapier' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Webhook URL
|
||||
</label>
|
||||
<Input
|
||||
value="https://hooks.zapier.com/hooks/catch/123456/abcdef/"
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Copy this URL to your Zapier trigger
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Events to Send
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" defaultChecked />
|
||||
<span className="text-sm">QR Code Scanned</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" defaultChecked />
|
||||
<span className="text-sm">QR Code Created</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
<span className="text-sm">QR Code Updated</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
|
||||
<pre className="text-xs text-gray-600 overflow-x-auto">
|
||||
{`{
|
||||
"event": "qr_scanned",
|
||||
"qr_id": "abc123",
|
||||
"title": "Product Page",
|
||||
"timestamp": "2025-01-01T12:00:00Z",
|
||||
"location": "United States",
|
||||
"device": "mobile"
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedIntegration.id === 'airtable' && (
|
||||
<>
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="key..."
|
||||
/>
|
||||
<Input
|
||||
label="Base ID"
|
||||
value=""
|
||||
placeholder="app..."
|
||||
/>
|
||||
<Input
|
||||
label="Table Name"
|
||||
value=""
|
||||
placeholder="QR Codes"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleTestConnection}>
|
||||
Test Connection
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedIntegration.id === 'google-sheets' && (
|
||||
<>
|
||||
<div className="text-center p-6">
|
||||
<Button>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
Connect Google Account
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
label="Spreadsheet URL"
|
||||
value=""
|
||||
placeholder="https://docs.google.com/spreadsheets/..."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button variant="outline" onClick={() => setShowSetupDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveIntegration}>
|
||||
Save Integration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Dialog } from '@/components/ui/Dialog';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface Integration {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
status: 'active' | 'inactive' | 'coming_soon';
|
||||
category: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null);
|
||||
const [showSetupDialog, setShowSetupDialog] = useState(false);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [webhookUrl, setWebhookUrl] = useState('');
|
||||
|
||||
const integrations: Integration[] = [
|
||||
{
|
||||
id: 'zapier',
|
||||
name: 'Zapier',
|
||||
description: 'Connect QR Master with 5,000+ apps',
|
||||
icon: '⚡',
|
||||
status: 'active',
|
||||
category: 'Automation',
|
||||
features: [
|
||||
'Trigger actions when QR codes are scanned',
|
||||
'Create QR codes from other apps',
|
||||
'Update QR destinations automatically',
|
||||
'Sync analytics to spreadsheets',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'airtable',
|
||||
name: 'Airtable',
|
||||
description: 'Sync QR codes with your Airtable bases',
|
||||
icon: '📊',
|
||||
status: 'inactive',
|
||||
category: 'Database',
|
||||
features: [
|
||||
'Two-way sync with Airtable',
|
||||
'Bulk import from bases',
|
||||
'Auto-update QR content',
|
||||
'Analytics dashboard integration',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'google-sheets',
|
||||
name: 'Google Sheets',
|
||||
description: 'Manage QR codes from spreadsheets',
|
||||
icon: '📈',
|
||||
status: 'inactive',
|
||||
category: 'Spreadsheet',
|
||||
features: [
|
||||
'Import QR codes from sheets',
|
||||
'Export analytics data',
|
||||
'Real-time sync',
|
||||
'Collaborative QR management',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Get QR scan notifications in Slack',
|
||||
icon: '💬',
|
||||
status: 'coming_soon',
|
||||
category: 'Communication',
|
||||
features: [
|
||||
'Real-time scan notifications',
|
||||
'Daily analytics summaries',
|
||||
'Team collaboration',
|
||||
'Custom alert rules',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhooks',
|
||||
description: 'Send data to any URL',
|
||||
icon: '🔗',
|
||||
status: 'active',
|
||||
category: 'Developer',
|
||||
features: [
|
||||
'Custom webhook endpoints',
|
||||
'Real-time event streaming',
|
||||
'Retry logic',
|
||||
'Event filtering',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
name: 'REST API',
|
||||
description: 'Full programmatic access',
|
||||
icon: '🔧',
|
||||
status: 'active',
|
||||
category: 'Developer',
|
||||
features: [
|
||||
'Complete CRUD operations',
|
||||
'Bulk operations',
|
||||
'Analytics API',
|
||||
'Rate limiting: 1000 req/hour',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const stats = {
|
||||
totalQRCodes: 234,
|
||||
activeIntegrations: 2,
|
||||
syncStatus: 'Synced',
|
||||
availableServices: 6,
|
||||
};
|
||||
|
||||
const handleActivate = (integration: Integration) => {
|
||||
setSelectedIntegration(integration);
|
||||
setShowSetupDialog(true);
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
// Simulate API test
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
alert('Connection successful!');
|
||||
};
|
||||
|
||||
const handleSaveIntegration = () => {
|
||||
setShowSetupDialog(false);
|
||||
// Update integration status
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('integrations.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">{t('integrations.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">QR Codes Total</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalQRCodes}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Active Integrations</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.activeIntegrations}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-success-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-success-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Sync Status</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.syncStatus}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-info-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-info-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Available Services</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.availableServices}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Integration Cards */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{integrations.map((integration) => (
|
||||
<Card key={integration.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-3xl">{integration.icon}</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{integration.name}</CardTitle>
|
||||
<Badge
|
||||
variant={
|
||||
integration.status === 'active' ? 'success' :
|
||||
integration.status === 'coming_soon' ? 'warning' :
|
||||
'default'
|
||||
}
|
||||
className="mt-1"
|
||||
>
|
||||
{integration.status === 'active' ? 'Active' :
|
||||
integration.status === 'coming_soon' ? 'Coming Soon' :
|
||||
'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-4">{integration.description}</p>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{integration.features.slice(0, 3).map((feature, index) => (
|
||||
<div key={index} className="flex items-start space-x-2">
|
||||
<svg className="w-4 h-4 text-success-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{integration.status === 'active' ? (
|
||||
<Button variant="outline" className="w-full">
|
||||
Configure
|
||||
</Button>
|
||||
) : integration.status === 'coming_soon' ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Coming Soon
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={() => handleActivate(integration)}>
|
||||
Activate & Configure
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Setup Dialog */}
|
||||
{showSetupDialog && selectedIntegration && (
|
||||
<Dialog
|
||||
open={showSetupDialog}
|
||||
onOpenChange={setShowSetupDialog}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
|
||||
<h2 className="text-lg font-semibold mb-4">Setup {selectedIntegration.name}</h2>
|
||||
<div className="space-y-4">
|
||||
{selectedIntegration.id === 'zapier' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Webhook URL
|
||||
</label>
|
||||
<Input
|
||||
value="https://hooks.zapier.com/hooks/catch/123456/abcdef/"
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Copy this URL to your Zapier trigger
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Events to Send
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" defaultChecked />
|
||||
<span className="text-sm">QR Code Scanned</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" defaultChecked />
|
||||
<span className="text-sm">QR Code Created</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
<span className="text-sm">QR Code Updated</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
|
||||
<pre className="text-xs text-gray-600 overflow-x-auto">
|
||||
{`{
|
||||
"event": "qr_scanned",
|
||||
"qr_id": "abc123",
|
||||
"title": "Product Page",
|
||||
"timestamp": "2025-01-01T12:00:00Z",
|
||||
"location": "United States",
|
||||
"device": "mobile"
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedIntegration.id === 'airtable' && (
|
||||
<>
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="key..."
|
||||
/>
|
||||
<Input
|
||||
label="Base ID"
|
||||
value=""
|
||||
placeholder="app..."
|
||||
/>
|
||||
<Input
|
||||
label="Table Name"
|
||||
value=""
|
||||
placeholder="QR Codes"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleTestConnection}>
|
||||
Test Connection
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedIntegration.id === 'google-sheets' && (
|
||||
<>
|
||||
<div className="text-center p-6">
|
||||
<Button>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
Connect Google Account
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
label="Spreadsheet URL"
|
||||
value=""
|
||||
placeholder="https://docs.google.com/spreadsheets/..."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button variant="outline" onClick={() => setShowSetupDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveIntegration}>
|
||||
Save Integration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,386 +1,386 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
||||
|
||||
type TabType = 'profile' | 'subscription';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
|
||||
// Profile states
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
// Subscription states
|
||||
const [plan, setPlan] = useState('FREE');
|
||||
const [usageStats, setUsageStats] = useState({
|
||||
dynamicUsed: 0,
|
||||
dynamicLimit: 3,
|
||||
staticUsed: 0,
|
||||
});
|
||||
|
||||
// Load user data
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
// Load from localStorage
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
setName(user.name || '');
|
||||
setEmail(user.email || '');
|
||||
}
|
||||
|
||||
// Fetch plan from API
|
||||
const planResponse = await fetch('/api/user/plan');
|
||||
if (planResponse.ok) {
|
||||
const data = await planResponse.json();
|
||||
setPlan(data.plan || 'FREE');
|
||||
}
|
||||
|
||||
// Fetch usage stats from API
|
||||
const statsResponse = await fetch('/api/user/stats');
|
||||
if (statsResponse.ok) {
|
||||
const data = await statsResponse.json();
|
||||
setUsageStats(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load user data:', e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, []);
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Save to backend API
|
||||
const response = await fetchWithCsrf('/api/user/profile', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update profile');
|
||||
}
|
||||
|
||||
// Update user data in localStorage
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
user.name = name;
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
showToast('Profile updated successfully!', 'success');
|
||||
} catch (error: any) {
|
||||
console.error('Error saving profile:', error);
|
||||
showToast(error.message || 'Failed to update profile', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/stripe/portal', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to open subscription management');
|
||||
}
|
||||
|
||||
// Redirect to Stripe Customer Portal
|
||||
window.location.href = data.url;
|
||||
} catch (error: any) {
|
||||
console.error('Error opening portal:', error);
|
||||
showToast(error.message || 'Failed to open subscription management', 'error');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to delete your account? This will permanently delete all your data, including all QR codes and analytics. This action cannot be undone.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
// Double confirmation for safety
|
||||
const doubleConfirmed = window.confirm(
|
||||
'This is your last warning. Are you absolutely sure you want to permanently delete your account?'
|
||||
);
|
||||
|
||||
if (!doubleConfirmed) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/user/delete', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete account');
|
||||
}
|
||||
|
||||
// Clear local storage and redirect to login
|
||||
localStorage.clear();
|
||||
showToast('Account deleted successfully', 'success');
|
||||
|
||||
// Redirect to home page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting account:', error);
|
||||
showToast(error.message || 'Failed to delete account', 'error');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanLimits = () => {
|
||||
switch (plan) {
|
||||
case 'PRO':
|
||||
return { dynamic: 50, price: '€9', period: 'per month' };
|
||||
case 'BUSINESS':
|
||||
return { dynamic: 500, price: '€29', period: 'per month' };
|
||||
default:
|
||||
return { dynamic: 3, price: '€0', period: 'forever' };
|
||||
}
|
||||
};
|
||||
|
||||
const planLimits = getPlanLimits();
|
||||
const usagePercentage = (usageStats.dynamicUsed / usageStats.dynamicLimit) * 100;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="text-gray-600 mt-2">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'profile'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('subscription')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'subscription'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Subscription
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-6">
|
||||
{/* Profile Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Email cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Password</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Update your password to keep your account secure
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPasswordModal(true)}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Deletion */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600">Delete Account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Delete your account</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Permanently delete your account and all data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-600 text-red-600 hover:bg-red-50"
|
||||
onClick={handleDeleteAccount}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={loading}
|
||||
size="lg"
|
||||
variant="primary"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-6">
|
||||
{/* Current Plan */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Current Plan</CardTitle>
|
||||
<Badge variant={plan === 'FREE' ? 'default' : plan === 'PRO' ? 'info' : 'warning'}>
|
||||
{plan}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-4xl font-bold">{planLimits.price}</span>
|
||||
<span className="text-gray-600 ml-2">{planLimits.period}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Dynamic QR Codes</span>
|
||||
<span className="font-medium">
|
||||
{usageStats.dynamicUsed} of {usageStats.dynamicLimit} used
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Static QR Codes</span>
|
||||
<span className="font-medium">Unlimited ∞</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-success-600 h-2 rounded-full" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plan !== 'FREE' && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => window.location.href = '/pricing'}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan === 'FREE' && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/pricing'}>
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<ChangePasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={() => setShowPasswordModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowPasswordModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
||||
|
||||
type TabType = 'profile' | 'subscription';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
|
||||
// Profile states
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
// Subscription states
|
||||
const [plan, setPlan] = useState('FREE');
|
||||
const [usageStats, setUsageStats] = useState({
|
||||
dynamicUsed: 0,
|
||||
dynamicLimit: 3,
|
||||
staticUsed: 0,
|
||||
});
|
||||
|
||||
// Load user data
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
// Load from localStorage
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
setName(user.name || '');
|
||||
setEmail(user.email || '');
|
||||
}
|
||||
|
||||
// Fetch plan from API
|
||||
const planResponse = await fetch('/api/user/plan');
|
||||
if (planResponse.ok) {
|
||||
const data = await planResponse.json();
|
||||
setPlan(data.plan || 'FREE');
|
||||
}
|
||||
|
||||
// Fetch usage stats from API
|
||||
const statsResponse = await fetch('/api/user/stats');
|
||||
if (statsResponse.ok) {
|
||||
const data = await statsResponse.json();
|
||||
setUsageStats(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load user data:', e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, []);
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Save to backend API
|
||||
const response = await fetchWithCsrf('/api/user/profile', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update profile');
|
||||
}
|
||||
|
||||
// Update user data in localStorage
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
user.name = name;
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
showToast('Profile updated successfully!', 'success');
|
||||
} catch (error: any) {
|
||||
console.error('Error saving profile:', error);
|
||||
showToast(error.message || 'Failed to update profile', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/stripe/portal', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to open subscription management');
|
||||
}
|
||||
|
||||
// Redirect to Stripe Customer Portal
|
||||
window.location.href = data.url;
|
||||
} catch (error: any) {
|
||||
console.error('Error opening portal:', error);
|
||||
showToast(error.message || 'Failed to open subscription management', 'error');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to delete your account? This will permanently delete all your data, including all QR codes and analytics. This action cannot be undone.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
// Double confirmation for safety
|
||||
const doubleConfirmed = window.confirm(
|
||||
'This is your last warning. Are you absolutely sure you want to permanently delete your account?'
|
||||
);
|
||||
|
||||
if (!doubleConfirmed) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/user/delete', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete account');
|
||||
}
|
||||
|
||||
// Clear local storage and redirect to login
|
||||
localStorage.clear();
|
||||
showToast('Account deleted successfully', 'success');
|
||||
|
||||
// Redirect to home page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting account:', error);
|
||||
showToast(error.message || 'Failed to delete account', 'error');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanLimits = () => {
|
||||
switch (plan) {
|
||||
case 'PRO':
|
||||
return { dynamic: 50, price: '€9', period: 'per month' };
|
||||
case 'BUSINESS':
|
||||
return { dynamic: 500, price: '€29', period: 'per month' };
|
||||
default:
|
||||
return { dynamic: 3, price: '€0', period: 'forever' };
|
||||
}
|
||||
};
|
||||
|
||||
const planLimits = getPlanLimits();
|
||||
const usagePercentage = (usageStats.dynamicUsed / usageStats.dynamicLimit) * 100;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="text-gray-600 mt-2">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'profile'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('subscription')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'subscription'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Subscription
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-6">
|
||||
{/* Profile Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Email cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Password</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Update your password to keep your account secure
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPasswordModal(true)}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Deletion */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600">Delete Account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Delete your account</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Permanently delete your account and all data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-600 text-red-600 hover:bg-red-50"
|
||||
onClick={handleDeleteAccount}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={loading}
|
||||
size="lg"
|
||||
variant="primary"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-6">
|
||||
{/* Current Plan */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Current Plan</CardTitle>
|
||||
<Badge variant={plan === 'FREE' ? 'default' : plan === 'PRO' ? 'info' : 'warning'}>
|
||||
{plan}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-4xl font-bold">{planLimits.price}</span>
|
||||
<span className="text-gray-600 ml-2">{planLimits.period}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Dynamic QR Codes</span>
|
||||
<span className="font-medium">
|
||||
{usageStats.dynamicUsed} of {usageStats.dynamicLimit} used
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Static QR Codes</span>
|
||||
<span className="font-medium">Unlimited ∞</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-success-600 h-2 rounded-full" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plan !== 'FREE' && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => window.location.href = '/pricing'}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan === 'FREE' && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/pricing'}>
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<ChangePasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={() => setShowPasswordModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowPasswordModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,158 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
|
||||
export default function TestPage() {
|
||||
const [testResults, setTestResults] = useState<any>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const runTest = async () => {
|
||||
setLoading(true);
|
||||
const results: any = {};
|
||||
|
||||
try {
|
||||
// Step 1: Create a STATIC QR code
|
||||
console.log('Creating STATIC QR code...');
|
||||
const createResponse = await fetch('/api/qrs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Test Static QR',
|
||||
contentType: 'URL',
|
||||
content: { url: 'https://google.com' },
|
||||
isStatic: true,
|
||||
tags: [],
|
||||
style: {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cornerStyle: 'square',
|
||||
size: 200,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const createdQR = await createResponse.json();
|
||||
results.created = createdQR;
|
||||
console.log('Created QR:', createdQR);
|
||||
|
||||
// Step 2: Fetch all QR codes
|
||||
console.log('Fetching QR codes...');
|
||||
const fetchResponse = await fetch('/api/qrs');
|
||||
const allQRs = await fetchResponse.json();
|
||||
results.fetched = allQRs;
|
||||
console.log('Fetched QRs:', allQRs);
|
||||
|
||||
// Step 3: Check debug endpoint
|
||||
console.log('Checking debug endpoint...');
|
||||
const debugResponse = await fetch('/api/debug');
|
||||
const debugData = await debugResponse.json();
|
||||
results.debug = debugData;
|
||||
console.log('Debug data:', debugData);
|
||||
|
||||
} catch (error) {
|
||||
results.error = String(error);
|
||||
console.error('Test error:', error);
|
||||
}
|
||||
|
||||
setTestResults(results);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getQRValue = (qr: any) => {
|
||||
// Check for qrContent field
|
||||
if (qr?.content?.qrContent) {
|
||||
return qr.content.qrContent;
|
||||
}
|
||||
// Check for direct URL
|
||||
if (qr?.content?.url) {
|
||||
return qr.content.url;
|
||||
}
|
||||
// Fallback to redirect
|
||||
return `http://localhost:3001/r/${qr?.slug || 'unknown'}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">QR Code Test Page</h1>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Test Static QR Code Creation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={runTest} loading={loading}>
|
||||
Run Test
|
||||
</Button>
|
||||
|
||||
{testResults.created && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-semibold mb-2">Created QR Code:</h3>
|
||||
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(testResults.created, null, 2)}
|
||||
</pre>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-semibold mb-2">QR Code Preview:</h4>
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<QRCodeSVG
|
||||
value={getQRValue(testResults.created)}
|
||||
size={200}
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
QR Value: {getQRValue(testResults.created)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResults.fetched && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-semibold mb-2">All QR Codes:</h3>
|
||||
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
|
||||
{JSON.stringify(testResults.fetched, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResults.debug && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-semibold mb-2">Debug Data:</h3>
|
||||
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
|
||||
{JSON.stringify(testResults.debug, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResults.error && (
|
||||
<div className="mt-6 p-4 bg-red-50 text-red-600 rounded">
|
||||
Error: {testResults.error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Manual QR Tests</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Direct URL QR (Should go to Google):</h3>
|
||||
<QRCodeSVG value="https://google.com" size={150} />
|
||||
<p className="text-sm text-gray-600 mt-1">Value: https://google.com</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Redirect QR (Goes through localhost):</h3>
|
||||
<QRCodeSVG value="http://localhost:3001/r/test-slug" size={150} />
|
||||
<p className="text-sm text-gray-600 mt-1">Value: http://localhost:3001/r/test-slug</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
|
||||
export default function TestPage() {
|
||||
const [testResults, setTestResults] = useState<any>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const runTest = async () => {
|
||||
setLoading(true);
|
||||
const results: any = {};
|
||||
|
||||
try {
|
||||
// Step 1: Create a STATIC QR code
|
||||
console.log('Creating STATIC QR code...');
|
||||
const createResponse = await fetch('/api/qrs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Test Static QR',
|
||||
contentType: 'URL',
|
||||
content: { url: 'https://google.com' },
|
||||
isStatic: true,
|
||||
tags: [],
|
||||
style: {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cornerStyle: 'square',
|
||||
size: 200,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const createdQR = await createResponse.json();
|
||||
results.created = createdQR;
|
||||
console.log('Created QR:', createdQR);
|
||||
|
||||
// Step 2: Fetch all QR codes
|
||||
console.log('Fetching QR codes...');
|
||||
const fetchResponse = await fetch('/api/qrs');
|
||||
const allQRs = await fetchResponse.json();
|
||||
results.fetched = allQRs;
|
||||
console.log('Fetched QRs:', allQRs);
|
||||
|
||||
// Step 3: Check debug endpoint
|
||||
console.log('Checking debug endpoint...');
|
||||
const debugResponse = await fetch('/api/debug');
|
||||
const debugData = await debugResponse.json();
|
||||
results.debug = debugData;
|
||||
console.log('Debug data:', debugData);
|
||||
|
||||
} catch (error) {
|
||||
results.error = String(error);
|
||||
console.error('Test error:', error);
|
||||
}
|
||||
|
||||
setTestResults(results);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getQRValue = (qr: any) => {
|
||||
// Check for qrContent field
|
||||
if (qr?.content?.qrContent) {
|
||||
return qr.content.qrContent;
|
||||
}
|
||||
// Check for direct URL
|
||||
if (qr?.content?.url) {
|
||||
return qr.content.url;
|
||||
}
|
||||
// Fallback to redirect
|
||||
return `http://localhost:3001/r/${qr?.slug || 'unknown'}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">QR Code Test Page</h1>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Test Static QR Code Creation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={runTest} loading={loading}>
|
||||
Run Test
|
||||
</Button>
|
||||
|
||||
{testResults.created && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-semibold mb-2">Created QR Code:</h3>
|
||||
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(testResults.created, null, 2)}
|
||||
</pre>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-semibold mb-2">QR Code Preview:</h4>
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<QRCodeSVG
|
||||
value={getQRValue(testResults.created)}
|
||||
size={200}
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
QR Value: {getQRValue(testResults.created)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResults.fetched && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-semibold mb-2">All QR Codes:</h3>
|
||||
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
|
||||
{JSON.stringify(testResults.fetched, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResults.debug && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-semibold mb-2">Debug Data:</h3>
|
||||
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
|
||||
{JSON.stringify(testResults.debug, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResults.error && (
|
||||
<div className="mt-6 p-4 bg-red-50 text-red-600 rounded">
|
||||
Error: {testResults.error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Manual QR Tests</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Direct URL QR (Should go to Google):</h3>
|
||||
<QRCodeSVG value="https://google.com" size={150} />
|
||||
<p className="text-sm text-gray-600 mt-1">Value: https://google.com</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Redirect QR (Goes through localhost):</h3>
|
||||
<QRCodeSVG value="http://localhost:3001/r/test-slug" size={150} />
|
||||
<p className="text-sm text-gray-600 mt-1">Value: http://localhost:3001/r/test-slug</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(data.error || 'Failed to send reset email');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Check Your Email</h1>
|
||||
<p className="text-gray-600 mt-2">We've sent you a password reset link</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-4">
|
||||
We've sent a password reset link to <strong>{email}</strong>
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Please check your email and click the link to reset your password. The link will expire in 1 hour.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link href="/login" className="block">
|
||||
<Button variant="primary" className="w-full">
|
||||
Back to Login
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSuccess(false);
|
||||
setEmail('');
|
||||
}}
|
||||
className="w-full text-primary-600 hover:text-primary-700 text-sm font-medium"
|
||||
>
|
||||
Try a different email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Forgot Password?</h1>
|
||||
<p className="text-gray-600 mt-2">No worries, we'll send you reset instructions</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
disabled={loading || csrfLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={csrfLoading || loading}
|
||||
>
|
||||
{csrfLoading ? 'Loading...' : 'Send Reset Link'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||
← Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Remember your password?{' '}
|
||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(data.error || 'Failed to send reset email');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Check Your Email</h1>
|
||||
<p className="text-gray-600 mt-2">We've sent you a password reset link</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-4">
|
||||
We've sent a password reset link to <strong>{email}</strong>
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Please check your email and click the link to reset your password. The link will expire in 1 hour.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link href="/login" className="block">
|
||||
<Button variant="primary" className="w-full">
|
||||
Back to Login
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSuccess(false);
|
||||
setEmail('');
|
||||
}}
|
||||
className="w-full text-primary-600 hover:text-primary-700 text-sm font-medium"
|
||||
>
|
||||
Try a different email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Forgot Password?</h1>
|
||||
<p className="text-gray-600 mt-2">No worries, we'll send you reset instructions</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
disabled={loading || csrfLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={csrfLoading || loading}
|
||||
>
|
||||
{csrfLoading ? 'Loading...' : 'Send Reset Link'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||
← Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Remember your password?{' '}
|
||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
import { getPublishedPostBySlug } from '@/lib/content';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
const RAW_ENABLED_SLUGS = new Set([
|
||||
'dynamic-vs-static-qr-codes',
|
||||
'qr-code-small-business',
|
||||
'qr-code-tracking-guide-2025',
|
||||
'utm-parameter-qr-codes',
|
||||
'trackable-qr-codes',
|
||||
]);
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/—/g, '--')
|
||||
.replace(/–/g, '-')
|
||||
.replace(/…/g, '...')
|
||||
.replace(/'/g, "'")
|
||||
.replace(///g, '/')
|
||||
.replace(/&#(\d+);/g, (_, code) => {
|
||||
const value = Number.parseInt(code, 10);
|
||||
return Number.isNaN(value) ? '' : String.fromCharCode(value);
|
||||
});
|
||||
}
|
||||
|
||||
function cleanHtmlToText(html: string): string {
|
||||
const normalized = html
|
||||
.replace(/<div\b[^>]*class=(['"])[^'"]*post-metadata[^'"]*\1[^>]*>[\s\S]*?<\/div>/gi, '')
|
||||
.replace(/<div\b[^>]*class=(['"])[^'"]*blog-content[^'"]*\1[^>]*>/gi, '')
|
||||
.replace(/<\/div>\s*$/i, '');
|
||||
|
||||
const withLinks = normalized.replace(
|
||||
/<a\b[^>]*href=(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi,
|
||||
(_, __, href: string, text: string) => `[${cleanHtmlToText(text)}](${href})`,
|
||||
);
|
||||
|
||||
const structured = withLinks
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<li\b[^>]*>/gi, '- ')
|
||||
.replace(/<\/li>/gi, '\n')
|
||||
.replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `${'#'.repeat(Number.parseInt(level, 10))} `)
|
||||
.replace(/<\/h[1-6]>/gi, '\n\n')
|
||||
.replace(/<\/p>/gi, '\n\n')
|
||||
.replace(/<\/div>/gi, '\n\n')
|
||||
.replace(/<\/section>/gi, '\n\n')
|
||||
.replace(/<\/ul>/gi, '\n')
|
||||
.replace(/<\/ol>/gi, '\n');
|
||||
|
||||
const stripped = sanitizeHtml(structured, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
});
|
||||
|
||||
return decodeHtmlEntities(stripped)
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderRawPost(slug: string): string | null {
|
||||
if (!RAW_ENABLED_SLUGS.has(slug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const post = getPublishedPostBySlug(slug);
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sections: string[] = [
|
||||
`# ${post.title}`,
|
||||
'',
|
||||
post.description,
|
||||
'',
|
||||
`Canonical URL: https://www.qrmaster.net/blog/${post.slug}`,
|
||||
`Published: ${post.datePublished}`,
|
||||
`Updated: ${post.dateModified || post.updatedAt || post.datePublished}`,
|
||||
];
|
||||
|
||||
if (post.quickAnswer) {
|
||||
sections.push('', '## Quick Answer', '', cleanHtmlToText(post.quickAnswer));
|
||||
}
|
||||
|
||||
if (post.keySteps?.length) {
|
||||
sections.push('', '## Steps', '', ...post.keySteps.map((step, index) => `${index + 1}. ${step}`));
|
||||
}
|
||||
|
||||
const mainText = cleanHtmlToText(post.content);
|
||||
if (mainText) {
|
||||
sections.push('', '## Article', '', mainText);
|
||||
}
|
||||
|
||||
if (post.faq?.length) {
|
||||
sections.push('', '## FAQ', '');
|
||||
for (const item of post.faq) {
|
||||
sections.push(`Q: ${cleanHtmlToText(item.question)}`);
|
||||
sections.push(`A: ${cleanHtmlToText(item.answer)}`, '');
|
||||
}
|
||||
if (sections[sections.length - 1] === '') {
|
||||
sections.pop();
|
||||
}
|
||||
}
|
||||
|
||||
if (post.sources?.length) {
|
||||
sections.push('', '## Sources', '');
|
||||
for (const source of post.sources) {
|
||||
const accessDate = source.accessDate ? ` (accessed ${source.accessDate})` : '';
|
||||
sections.push(`- ${source.name}: ${source.url}${accessDate}`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${sections.join('\n').trim()}\n`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: { slug: string } },
|
||||
) {
|
||||
const content = renderRawPost(params.slug);
|
||||
|
||||
if (!content) {
|
||||
return new Response('Not Found', {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Robots-Tag': 'noindex, nofollow',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'X-Robots-Tag': 'noindex, nofollow',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
import { getPublishedPostBySlug } from '@/lib/content';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
const RAW_ENABLED_SLUGS = new Set([
|
||||
'dynamic-vs-static-qr-codes',
|
||||
'qr-code-small-business',
|
||||
'qr-code-tracking-guide-2025',
|
||||
'utm-parameter-qr-codes',
|
||||
'trackable-qr-codes',
|
||||
]);
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/—/g, '--')
|
||||
.replace(/–/g, '-')
|
||||
.replace(/…/g, '...')
|
||||
.replace(/'/g, "'")
|
||||
.replace(///g, '/')
|
||||
.replace(/&#(\d+);/g, (_, code) => {
|
||||
const value = Number.parseInt(code, 10);
|
||||
return Number.isNaN(value) ? '' : String.fromCharCode(value);
|
||||
});
|
||||
}
|
||||
|
||||
function cleanHtmlToText(html: string): string {
|
||||
const normalized = html
|
||||
.replace(/<div\b[^>]*class=(['"])[^'"]*post-metadata[^'"]*\1[^>]*>[\s\S]*?<\/div>/gi, '')
|
||||
.replace(/<div\b[^>]*class=(['"])[^'"]*blog-content[^'"]*\1[^>]*>/gi, '')
|
||||
.replace(/<\/div>\s*$/i, '');
|
||||
|
||||
const withLinks = normalized.replace(
|
||||
/<a\b[^>]*href=(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi,
|
||||
(_, __, href: string, text: string) => `[${cleanHtmlToText(text)}](${href})`,
|
||||
);
|
||||
|
||||
const structured = withLinks
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<li\b[^>]*>/gi, '- ')
|
||||
.replace(/<\/li>/gi, '\n')
|
||||
.replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `${'#'.repeat(Number.parseInt(level, 10))} `)
|
||||
.replace(/<\/h[1-6]>/gi, '\n\n')
|
||||
.replace(/<\/p>/gi, '\n\n')
|
||||
.replace(/<\/div>/gi, '\n\n')
|
||||
.replace(/<\/section>/gi, '\n\n')
|
||||
.replace(/<\/ul>/gi, '\n')
|
||||
.replace(/<\/ol>/gi, '\n');
|
||||
|
||||
const stripped = sanitizeHtml(structured, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
});
|
||||
|
||||
return decodeHtmlEntities(stripped)
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderRawPost(slug: string): string | null {
|
||||
if (!RAW_ENABLED_SLUGS.has(slug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const post = getPublishedPostBySlug(slug);
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sections: string[] = [
|
||||
`# ${post.title}`,
|
||||
'',
|
||||
post.description,
|
||||
'',
|
||||
`Canonical URL: https://www.qrmaster.net/blog/${post.slug}`,
|
||||
`Published: ${post.datePublished}`,
|
||||
`Updated: ${post.dateModified || post.updatedAt || post.datePublished}`,
|
||||
];
|
||||
|
||||
if (post.quickAnswer) {
|
||||
sections.push('', '## Quick Answer', '', cleanHtmlToText(post.quickAnswer));
|
||||
}
|
||||
|
||||
if (post.keySteps?.length) {
|
||||
sections.push('', '## Steps', '', ...post.keySteps.map((step, index) => `${index + 1}. ${step}`));
|
||||
}
|
||||
|
||||
const mainText = cleanHtmlToText(post.content);
|
||||
if (mainText) {
|
||||
sections.push('', '## Article', '', mainText);
|
||||
}
|
||||
|
||||
if (post.faq?.length) {
|
||||
sections.push('', '## FAQ', '');
|
||||
for (const item of post.faq) {
|
||||
sections.push(`Q: ${cleanHtmlToText(item.question)}`);
|
||||
sections.push(`A: ${cleanHtmlToText(item.answer)}`, '');
|
||||
}
|
||||
if (sections[sections.length - 1] === '') {
|
||||
sections.pop();
|
||||
}
|
||||
}
|
||||
|
||||
if (post.sources?.length) {
|
||||
sections.push('', '## Sources', '');
|
||||
for (const source of post.sources) {
|
||||
const accessDate = source.accessDate ? ` (accessed ${source.accessDate})` : '';
|
||||
sections.push(`- ${source.name}: ${source.url}${accessDate}`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${sections.join('\n').trim()}\n`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: { slug: string } },
|
||||
) {
|
||||
const content = renderRawPost(params.slug);
|
||||
|
||||
if (!content) {
|
||||
return new Response('Not Found', {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Robots-Tag': 'noindex, nofollow',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'X-Robots-Tag': 'noindex, nofollow',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,243 +1,243 @@
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { faqPageSchema } from '@/lib/schema';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
||||
|
||||
function truncateAtWord(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const truncated = text.slice(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Master FAQ: Dynamic, Tracking, Bulk, and Print', 60);
|
||||
const description = truncateAtWord(
|
||||
'Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.',
|
||||
160
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/faq',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/faq',
|
||||
en: 'https://www.qrmaster.net/faq',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: 'https://www.qrmaster.net/faq',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master FAQ',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type FAQItemWithRichText = {
|
||||
question: string;
|
||||
answer: string;
|
||||
answerRich?: React.ReactNode;
|
||||
};
|
||||
|
||||
const faqs: FAQItemWithRichText[] = [
|
||||
{
|
||||
question: 'What is a dynamic QR code?',
|
||||
answer:
|
||||
'A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.',
|
||||
answerRich: (
|
||||
<>
|
||||
A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Why teams use it:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Update the destination after print</li>
|
||||
<li>Review scan analytics later</li>
|
||||
<li>Keep one printed QR in use across changing campaigns or content</li>
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'How do I track QR scans?',
|
||||
answer:
|
||||
'QR Master tracks scans through the dynamic QR redirect step. The analytics views can report time, device, location context, and total or unique scan activity.',
|
||||
answerRich: (
|
||||
<>
|
||||
QR Master tracks scans through the dynamic QR redirect step.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Current analytics context:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Total and unique scan reporting</li>
|
||||
<li>Device type</li>
|
||||
<li>Location context</li>
|
||||
<li>Time-based scan activity</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Link href="/qr-code-tracking" className="font-medium text-blue-600 hover:underline">
|
||||
Learn more about tracking
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'What security measures are in place?',
|
||||
answer:
|
||||
'QR Master uses HTTPS/TLS, CSRF protection for relevant write actions, and rate limiting on API routes.',
|
||||
answerRich: (
|
||||
<>
|
||||
QR Master uses standard protective controls that are visible in the current codebase.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Security-related controls:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>HTTPS/TLS encryption for all connections</li>
|
||||
<li>CSRF protection for relevant write actions</li>
|
||||
<li>Rate limiting on API routes</li>
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'How does bulk QR creation work today?',
|
||||
answer:
|
||||
'QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan. The flow accepts CSV, XLS, and XLSX files, supports up to 1,000 rows per upload, and generates static QR codes.',
|
||||
answerRich: (
|
||||
<>
|
||||
QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Current bulk flow facts:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>CSV, XLS, and XLSX uploads are supported</li>
|
||||
<li>Up to 1,000 rows per upload</li>
|
||||
<li>Output is static QR codes, not dynamic tracking batches</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Link href="/bulk-qr-code-generator" className="font-medium text-blue-600 hover:underline">
|
||||
See the bulk QR workflow
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'What are the best practices for printing QR codes?',
|
||||
answer:
|
||||
'For reliable scanning, keep the QR code at least 2x2 cm for close-range use, maintain strong contrast, leave a quiet zone around the code, and use SVG or a high-resolution PNG for output.',
|
||||
answerRich: (
|
||||
<>
|
||||
For reliable scanning, follow these print-first basics:
|
||||
<br />
|
||||
<br />
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Minimum size around 2x2 cm for close-range scans</li>
|
||||
<li>Dark foreground on a light background</li>
|
||||
<li>Leave a quiet zone around the QR code</li>
|
||||
<li>Use SVG or a high-resolution PNG depending on the print workflow</li>
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'Is the service privacy-conscious?',
|
||||
answer:
|
||||
'QR Master minimizes scanner data collection. Privacy-related measures visible in the product context include hashed or anonymized IP handling and no scanner PII storage.',
|
||||
answerRich: (
|
||||
<>
|
||||
QR Master is built around minimal scanner data collection.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Privacy-related measures:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>IP addresses are anonymized or hashed</li>
|
||||
<li>No scanner PII storage</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Link href="/privacy" className="font-medium text-blue-600 hover:underline">
|
||||
Read the privacy policy
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between static and dynamic QR codes?',
|
||||
answer:
|
||||
'Static QR codes store the destination directly in the image and stay fixed. Dynamic QR codes route through QR Master so the destination can be changed later and scan analytics can be reviewed.',
|
||||
answerRich: (
|
||||
<>
|
||||
Static QR codes store the destination directly in the image and stay fixed.
|
||||
Dynamic QR codes route through QR Master so the destination can be changed later and scan analytics can be reviewed.
|
||||
<br />
|
||||
<br />
|
||||
<Link href="/dynamic-qr-code-generator" className="font-medium text-blue-600 hover:underline">
|
||||
Create a dynamic QR code
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function FAQPage() {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={faqPageSchema(faqs.map(({ question, answer }) => ({ question, answer })))} />
|
||||
<div className="bg-gradient-to-b from-gray-50 to-white py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-16 text-center">
|
||||
<h1 className="mb-6 text-4xl font-bold text-gray-900 lg:text-5xl">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
<p className="mb-4 text-xl text-gray-600">
|
||||
Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Last updated: March 12, 2026</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{faqs.map((faq) => (
|
||||
<Card key={faq.question} className="border-l-4 border-blue-500">
|
||||
<CardContent className="p-8">
|
||||
<h2 className="mb-4 text-2xl font-semibold text-gray-900">{faq.question}</h2>
|
||||
<div className="text-lg leading-relaxed text-gray-700">{faq.answerRich || faq.answer}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 rounded-r-lg border-l-4 border-blue-500 bg-blue-50 p-8">
|
||||
<h2 className="mb-4 text-2xl font-bold text-gray-900">Still have questions?</h2>
|
||||
<p className="text-lg leading-relaxed text-gray-700">
|
||||
Our support team is here to help. Contact us at{' '}
|
||||
<ObfuscatedMailto
|
||||
email="support@qrmaster.net"
|
||||
className="font-semibold text-blue-600 hover:text-blue-700"
|
||||
/>{' '}
|
||||
and include the workflow you are trying to build.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { faqPageSchema } from '@/lib/schema';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
||||
|
||||
function truncateAtWord(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const truncated = text.slice(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Master FAQ: Dynamic, Tracking, Bulk, and Print', 60);
|
||||
const description = truncateAtWord(
|
||||
'Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.',
|
||||
160
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/faq',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/faq',
|
||||
en: 'https://www.qrmaster.net/faq',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: 'https://www.qrmaster.net/faq',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master FAQ',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type FAQItemWithRichText = {
|
||||
question: string;
|
||||
answer: string;
|
||||
answerRich?: React.ReactNode;
|
||||
};
|
||||
|
||||
const faqs: FAQItemWithRichText[] = [
|
||||
{
|
||||
question: 'What is a dynamic QR code?',
|
||||
answer:
|
||||
'A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.',
|
||||
answerRich: (
|
||||
<>
|
||||
A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Why teams use it:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Update the destination after print</li>
|
||||
<li>Review scan analytics later</li>
|
||||
<li>Keep one printed QR in use across changing campaigns or content</li>
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'How do I track QR scans?',
|
||||
answer:
|
||||
'QR Master tracks scans through the dynamic QR redirect step. The analytics views can report time, device, location context, and total or unique scan activity.',
|
||||
answerRich: (
|
||||
<>
|
||||
QR Master tracks scans through the dynamic QR redirect step.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Current analytics context:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Total and unique scan reporting</li>
|
||||
<li>Device type</li>
|
||||
<li>Location context</li>
|
||||
<li>Time-based scan activity</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Link href="/qr-code-tracking" className="font-medium text-blue-600 hover:underline">
|
||||
Learn more about tracking
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'What security measures are in place?',
|
||||
answer:
|
||||
'QR Master uses HTTPS/TLS, CSRF protection for relevant write actions, and rate limiting on API routes.',
|
||||
answerRich: (
|
||||
<>
|
||||
QR Master uses standard protective controls that are visible in the current codebase.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Security-related controls:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>HTTPS/TLS encryption for all connections</li>
|
||||
<li>CSRF protection for relevant write actions</li>
|
||||
<li>Rate limiting on API routes</li>
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'How does bulk QR creation work today?',
|
||||
answer:
|
||||
'QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan. The flow accepts CSV, XLS, and XLSX files, supports up to 1,000 rows per upload, and generates static QR codes.',
|
||||
answerRich: (
|
||||
<>
|
||||
QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Current bulk flow facts:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>CSV, XLS, and XLSX uploads are supported</li>
|
||||
<li>Up to 1,000 rows per upload</li>
|
||||
<li>Output is static QR codes, not dynamic tracking batches</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Link href="/bulk-qr-code-generator" className="font-medium text-blue-600 hover:underline">
|
||||
See the bulk QR workflow
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'What are the best practices for printing QR codes?',
|
||||
answer:
|
||||
'For reliable scanning, keep the QR code at least 2x2 cm for close-range use, maintain strong contrast, leave a quiet zone around the code, and use SVG or a high-resolution PNG for output.',
|
||||
answerRich: (
|
||||
<>
|
||||
For reliable scanning, follow these print-first basics:
|
||||
<br />
|
||||
<br />
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Minimum size around 2x2 cm for close-range scans</li>
|
||||
<li>Dark foreground on a light background</li>
|
||||
<li>Leave a quiet zone around the QR code</li>
|
||||
<li>Use SVG or a high-resolution PNG depending on the print workflow</li>
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'Is the service privacy-conscious?',
|
||||
answer:
|
||||
'QR Master minimizes scanner data collection. Privacy-related measures visible in the product context include hashed or anonymized IP handling and no scanner PII storage.',
|
||||
answerRich: (
|
||||
<>
|
||||
QR Master is built around minimal scanner data collection.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Privacy-related measures:</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>IP addresses are anonymized or hashed</li>
|
||||
<li>No scanner PII storage</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Link href="/privacy" className="font-medium text-blue-600 hover:underline">
|
||||
Read the privacy policy
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between static and dynamic QR codes?',
|
||||
answer:
|
||||
'Static QR codes store the destination directly in the image and stay fixed. Dynamic QR codes route through QR Master so the destination can be changed later and scan analytics can be reviewed.',
|
||||
answerRich: (
|
||||
<>
|
||||
Static QR codes store the destination directly in the image and stay fixed.
|
||||
Dynamic QR codes route through QR Master so the destination can be changed later and scan analytics can be reviewed.
|
||||
<br />
|
||||
<br />
|
||||
<Link href="/dynamic-qr-code-generator" className="font-medium text-blue-600 hover:underline">
|
||||
Create a dynamic QR code
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function FAQPage() {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={faqPageSchema(faqs.map(({ question, answer }) => ({ question, answer })))} />
|
||||
<div className="bg-gradient-to-b from-gray-50 to-white py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-16 text-center">
|
||||
<h1 className="mb-6 text-4xl font-bold text-gray-900 lg:text-5xl">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
<p className="mb-4 text-xl text-gray-600">
|
||||
Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Last updated: March 12, 2026</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{faqs.map((faq) => (
|
||||
<Card key={faq.question} className="border-l-4 border-blue-500">
|
||||
<CardContent className="p-8">
|
||||
<h2 className="mb-4 text-2xl font-semibold text-gray-900">{faq.question}</h2>
|
||||
<div className="text-lg leading-relaxed text-gray-700">{faq.answerRich || faq.answer}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 rounded-r-lg border-l-4 border-blue-500 bg-blue-50 p-8">
|
||||
<h2 className="mb-4 text-2xl font-bold text-gray-900">Still have questions?</h2>
|
||||
<p className="text-lg leading-relaxed text-gray-700">
|
||||
Our support team is here to help. Contact us at{' '}
|
||||
<ObfuscatedMailto
|
||||
email="support@qrmaster.net"
|
||||
className="font-semibold text-blue-600 hover:text-blue-700"
|
||||
/>{' '}
|
||||
and include the workflow you are trying to build.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,389 +1,389 @@
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||
import { breadcrumbSchema, faqPageSchema } from '@/lib/schema';
|
||||
import { AnswerFirstBlock } from '@/components/marketing/AnswerFirstBlock';
|
||||
import { FAQSection } from '@/components/aeo/FAQSection';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Manage QR Codes - Dashboard, Edits, and Analytics',
|
||||
},
|
||||
description:
|
||||
'Manage QR codes in one dashboard. Review active codes, edit dynamic destinations, see scan totals and unique scans, and work within current Free, Pro, or Business limits.',
|
||||
keywords: [
|
||||
'manage qr codes',
|
||||
'qr code dashboard',
|
||||
'edit dynamic qr codes',
|
||||
'qr code analytics dashboard',
|
||||
'qr code management',
|
||||
],
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/manage-qr-codes',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/manage-qr-codes',
|
||||
en: 'https://www.qrmaster.net/manage-qr-codes',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Manage QR Codes - Dashboard, Edits, and Analytics',
|
||||
description:
|
||||
'Use one dashboard to review QR codes, edit dynamic destinations, and check scan totals and unique scans.',
|
||||
url: 'https://www.qrmaster.net/manage-qr-codes',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: '/images/og/og-manage-qr-codes.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title: 'Manage QR Codes - Dashboard, Edits, and Analytics',
|
||||
description:
|
||||
'Use one dashboard to review QR codes, edit dynamic destinations, and check scan totals and unique scans.',
|
||||
},
|
||||
};
|
||||
|
||||
const verifiedCapabilities = [
|
||||
{
|
||||
title: 'Central dashboard',
|
||||
description:
|
||||
'The dashboard lists your QR codes in one place instead of forcing you to manage separate files or links manually.',
|
||||
},
|
||||
{
|
||||
title: 'Dynamic destination edits',
|
||||
description:
|
||||
'Dynamic QR codes can be edited after print. Static QR codes remain fixed.',
|
||||
},
|
||||
{
|
||||
title: 'Scan reporting',
|
||||
description:
|
||||
'The current dashboard reports total scans, active codes, and unique scans, with analytics pages adding more context.',
|
||||
},
|
||||
{
|
||||
title: 'Plan-based limits',
|
||||
description:
|
||||
'Free includes 3 dynamic QR codes, Pro includes 50, and Business includes 500. Static QR codes remain unlimited.',
|
||||
},
|
||||
{
|
||||
title: 'Tags and status',
|
||||
description:
|
||||
'QR code records support tags and active status, which helps keep batches and single-code workflows easier to review.',
|
||||
},
|
||||
{
|
||||
title: 'Download and delete actions',
|
||||
description:
|
||||
'Each QR code card supports view, download, edit for dynamic QR codes, and delete actions from the dashboard surface.',
|
||||
},
|
||||
];
|
||||
|
||||
const operationalUseCases = [
|
||||
{
|
||||
title: 'Marketing campaigns',
|
||||
description:
|
||||
'Review active dynamic QR codes, compare scan totals, and update destinations when campaigns or landing pages change.',
|
||||
points: ['One list of active codes', 'Scan totals and unique scans', 'Edit dynamic destinations'],
|
||||
},
|
||||
{
|
||||
title: 'Restaurants and hospitality',
|
||||
description:
|
||||
'Keep menu or table-card QR codes current from the dashboard instead of reprinting every time the destination changes.',
|
||||
points: ['Update menu destinations', 'Monitor scan activity', 'Keep print assets in use longer'],
|
||||
},
|
||||
{
|
||||
title: 'Product and packaging workflows',
|
||||
description:
|
||||
'Track which QR codes are active, save batches to the dashboard, and separate static bulk output from dynamic campaign codes.',
|
||||
points: ['Save generated QR codes', 'Review active status', 'Manage static and dynamic codes separately'],
|
||||
},
|
||||
{
|
||||
title: 'Small team or solo workflows',
|
||||
description:
|
||||
'Use one account to keep QR code creation, edits, downloads, and analytics in one operational place.',
|
||||
points: ['Single dashboard view', 'No spreadsheet-only workflow', 'Clear plan limits'],
|
||||
},
|
||||
];
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: 'What does it mean to manage QR codes?',
|
||||
answer:
|
||||
'In QR Master, managing QR codes means using one dashboard to review your QR codes, edit dynamic destinations, download files, and check scan activity instead of tracking everything manually.',
|
||||
},
|
||||
{
|
||||
question: 'Can I edit a QR code after printing it?',
|
||||
answer:
|
||||
'Yes, if it is a dynamic QR code. Static QR codes stay fixed after creation.',
|
||||
},
|
||||
{
|
||||
question: 'How many dynamic QR codes can I manage?',
|
||||
answer:
|
||||
'Free includes 3 dynamic QR codes, Pro includes 50, and Business includes 500. Static QR codes are unlimited.',
|
||||
},
|
||||
{
|
||||
question: 'What analytics are visible today?',
|
||||
answer:
|
||||
'The current dashboard shows total scans, active QR codes, and unique scans. Additional analytics views add more scan context such as time, device, and location.',
|
||||
},
|
||||
{
|
||||
question: 'Does the current product include team roles or API-based QR management?',
|
||||
answer:
|
||||
'This page only reflects the verified current surface: dashboard management, dynamic edits, analytics, downloads, tags, and plan limits. It does not claim team roles or public API-based QR management as current verified capabilities.',
|
||||
},
|
||||
];
|
||||
|
||||
const softwareSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': 'https://www.qrmaster.net/manage-qr-codes#software',
|
||||
name: 'QR Master - QR Code Management Dashboard',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
lowPrice: '0',
|
||||
highPrice: '29',
|
||||
priceCurrency: 'EUR',
|
||||
},
|
||||
featureList: [
|
||||
'Central QR code dashboard',
|
||||
'Edit dynamic QR code destinations',
|
||||
'Review total and unique scan counts',
|
||||
'Download QR codes as PNG or SVG',
|
||||
'Tag QR code records and review active status',
|
||||
'Manage current plan limits for dynamic QR codes',
|
||||
],
|
||||
};
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Manage QR Codes', url: '/manage-qr-codes' },
|
||||
];
|
||||
|
||||
export default function ManageQRCodesPage() {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd
|
||||
data={[
|
||||
softwareSchema,
|
||||
breadcrumbSchema(breadcrumbItems),
|
||||
faqPageSchema(faqItems),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-green-50 via-white to-blue-50 py-20">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
<div className="mt-8 grid items-center gap-12 lg:grid-cols-2">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center rounded-full bg-green-100 px-4 py-2 text-sm font-semibold text-green-800">
|
||||
Dashboard-first QR management
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-5xl font-bold leading-tight text-gray-900 lg:text-6xl">
|
||||
Manage QR Codes from one dashboard
|
||||
</h1>
|
||||
<p className="text-xl leading-relaxed text-gray-600">
|
||||
Review active QR codes, edit dynamic destinations, download files, and
|
||||
monitor scan totals from one place instead of managing printed QR workflows manually.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
'See QR codes in one dashboard',
|
||||
'Edit dynamic destinations after print',
|
||||
'Review total scans, active codes, and unique scans',
|
||||
'Work within the current Free, Pro, and Business dynamic QR limits',
|
||||
].map((feature) => (
|
||||
<div key={feature} className="flex items-center gap-3">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-green-500">
|
||||
<svg className="h-3 w-3 text-white" 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>
|
||||
</div>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="w-full px-8 py-4 text-lg sm:w-auto">
|
||||
Get Started Free
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button variant="outline" size="lg" className="w-full px-8 py-4 text-lg sm:w-auto">
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Card className="p-6 shadow-2xl">
|
||||
<h3 className="mb-4 text-lg font-semibold">Dashboard snapshot</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-700">Active QR codes</span>
|
||||
<span className="text-2xl font-bold text-blue-600">3 / 50 / 500</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Plan-based dynamic QR capacity</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<div className="mb-1 text-xs text-gray-600">Total scans</div>
|
||||
<div className="text-xl font-bold text-green-600">Dashboard metric</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||
<div className="mb-1 text-xs text-gray-600">Unique scans</div>
|
||||
<div className="text-xl font-bold text-purple-600">Dashboard metric</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="mb-2 text-xs text-gray-600">Available actions</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-gray-700">
|
||||
<span className="rounded-full bg-white px-3 py-1">View details</span>
|
||||
<span className="rounded-full bg-white px-3 py-1">Download PNG</span>
|
||||
<span className="rounded-full bg-white px-3 py-1">Download SVG</span>
|
||||
<span className="rounded-full bg-white px-3 py-1">Edit dynamic QR</span>
|
||||
<span className="rounded-full bg-white px-3 py-1">Delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<AnswerFirstBlock
|
||||
whatIsIt="QR Master management is a dashboard workflow for reviewing QR codes, editing dynamic destinations, downloading files, and checking scan activity from one place. It is most useful when your QR program is active enough that single-file handling becomes messy."
|
||||
whenToUse={[
|
||||
'You need one place to review active QR codes and their scan totals',
|
||||
'You want to edit dynamic QR destinations after print without replacing the QR image',
|
||||
'You need to keep static and dynamic QR workflows organized around current plan limits',
|
||||
]}
|
||||
comparison={{
|
||||
leftTitle: 'Manual handling',
|
||||
rightTitle: 'Dashboard management',
|
||||
items: [
|
||||
{ label: 'See active QR codes in one place', value: true, text: 'Scattered across files or exports' },
|
||||
{ label: 'Edit dynamic destinations later', value: true, text: 'Not possible outside dynamic QR management' },
|
||||
{ label: 'Review scan totals and unique scans', value: true, text: 'No unified dashboard view' },
|
||||
],
|
||||
}}
|
||||
howTo={{
|
||||
steps: [
|
||||
'Create or save QR codes into your QR Master account',
|
||||
'Open the dashboard to review active codes, scans, and available actions',
|
||||
'Edit dynamic destinations or download the QR files you need for the next workflow',
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto max-w-5xl px-4 pb-8 sm:px-6 lg:px-8">
|
||||
<FAQSection items={faqItems} title="QR management questions" />
|
||||
</div>
|
||||
|
||||
<section className="bg-gray-50 py-20">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="mb-4 text-4xl font-bold text-gray-900">What the current dashboard supports</h2>
|
||||
<p className="mx-auto max-w-3xl text-xl text-gray-600">
|
||||
These capabilities are tied to the present product surface rather than future or inferred roadmap features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{verifiedCapabilities.map((feature) => (
|
||||
<Card key={feature.title} className="p-6 transition-shadow hover:shadow-lg">
|
||||
<h3 className="mb-2 text-xl font-semibold text-gray-900">{feature.title}</h3>
|
||||
<p className="text-gray-600">{feature.description}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="mb-4 text-4xl font-bold text-gray-900">Where QR management is most useful</h2>
|
||||
<p className="mx-auto max-w-3xl text-xl text-gray-600">
|
||||
Use the dashboard when your QR workflows need ongoing edits, downloads, and visibility instead of one-off creation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{operationalUseCases.map((useCase) => (
|
||||
<Card key={useCase.title} className="p-8">
|
||||
<h3 className="mb-3 text-2xl font-bold text-gray-900">{useCase.title}</h3>
|
||||
<p className="mb-6 text-gray-600">{useCase.description}</p>
|
||||
<ul className="space-y-2">
|
||||
{useCase.points.map((point) => (
|
||||
<li key={point} className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 flex-shrink-0 text-green-500" 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>
|
||||
<span className="text-gray-700">{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-gradient-to-r from-green-600 to-blue-600 py-20 text-white">
|
||||
<div className="container mx-auto max-w-5xl px-4 text-center sm:px-6 lg:px-8">
|
||||
<h2 className="mb-6 text-4xl font-bold">Start managing your QR codes with one account</h2>
|
||||
<p className="mb-8 text-xl text-green-100">
|
||||
Keep dynamic updates, downloads, and scan reporting in one dashboard instead of spreading the workflow across files and ad hoc links.
|
||||
</p>
|
||||
<div className="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Link href="/signup">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
className="w-full bg-white px-8 py-4 text-lg text-green-600 hover:bg-gray-100 sm:w-auto"
|
||||
>
|
||||
Get Started Free
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full border-white px-8 py-4 text-lg text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||
import { breadcrumbSchema, faqPageSchema } from '@/lib/schema';
|
||||
import { AnswerFirstBlock } from '@/components/marketing/AnswerFirstBlock';
|
||||
import { FAQSection } from '@/components/aeo/FAQSection';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Manage QR Codes - Dashboard, Edits, and Analytics',
|
||||
},
|
||||
description:
|
||||
'Manage QR codes in one dashboard. Review active codes, edit dynamic destinations, see scan totals and unique scans, and work within current Free, Pro, or Business limits.',
|
||||
keywords: [
|
||||
'manage qr codes',
|
||||
'qr code dashboard',
|
||||
'edit dynamic qr codes',
|
||||
'qr code analytics dashboard',
|
||||
'qr code management',
|
||||
],
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/manage-qr-codes',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/manage-qr-codes',
|
||||
en: 'https://www.qrmaster.net/manage-qr-codes',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Manage QR Codes - Dashboard, Edits, and Analytics',
|
||||
description:
|
||||
'Use one dashboard to review QR codes, edit dynamic destinations, and check scan totals and unique scans.',
|
||||
url: 'https://www.qrmaster.net/manage-qr-codes',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: '/images/og/og-manage-qr-codes.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title: 'Manage QR Codes - Dashboard, Edits, and Analytics',
|
||||
description:
|
||||
'Use one dashboard to review QR codes, edit dynamic destinations, and check scan totals and unique scans.',
|
||||
},
|
||||
};
|
||||
|
||||
const verifiedCapabilities = [
|
||||
{
|
||||
title: 'Central dashboard',
|
||||
description:
|
||||
'The dashboard lists your QR codes in one place instead of forcing you to manage separate files or links manually.',
|
||||
},
|
||||
{
|
||||
title: 'Dynamic destination edits',
|
||||
description:
|
||||
'Dynamic QR codes can be edited after print. Static QR codes remain fixed.',
|
||||
},
|
||||
{
|
||||
title: 'Scan reporting',
|
||||
description:
|
||||
'The current dashboard reports total scans, active codes, and unique scans, with analytics pages adding more context.',
|
||||
},
|
||||
{
|
||||
title: 'Plan-based limits',
|
||||
description:
|
||||
'Free includes 3 dynamic QR codes, Pro includes 50, and Business includes 500. Static QR codes remain unlimited.',
|
||||
},
|
||||
{
|
||||
title: 'Tags and status',
|
||||
description:
|
||||
'QR code records support tags and active status, which helps keep batches and single-code workflows easier to review.',
|
||||
},
|
||||
{
|
||||
title: 'Download and delete actions',
|
||||
description:
|
||||
'Each QR code card supports view, download, edit for dynamic QR codes, and delete actions from the dashboard surface.',
|
||||
},
|
||||
];
|
||||
|
||||
const operationalUseCases = [
|
||||
{
|
||||
title: 'Marketing campaigns',
|
||||
description:
|
||||
'Review active dynamic QR codes, compare scan totals, and update destinations when campaigns or landing pages change.',
|
||||
points: ['One list of active codes', 'Scan totals and unique scans', 'Edit dynamic destinations'],
|
||||
},
|
||||
{
|
||||
title: 'Restaurants and hospitality',
|
||||
description:
|
||||
'Keep menu or table-card QR codes current from the dashboard instead of reprinting every time the destination changes.',
|
||||
points: ['Update menu destinations', 'Monitor scan activity', 'Keep print assets in use longer'],
|
||||
},
|
||||
{
|
||||
title: 'Product and packaging workflows',
|
||||
description:
|
||||
'Track which QR codes are active, save batches to the dashboard, and separate static bulk output from dynamic campaign codes.',
|
||||
points: ['Save generated QR codes', 'Review active status', 'Manage static and dynamic codes separately'],
|
||||
},
|
||||
{
|
||||
title: 'Small team or solo workflows',
|
||||
description:
|
||||
'Use one account to keep QR code creation, edits, downloads, and analytics in one operational place.',
|
||||
points: ['Single dashboard view', 'No spreadsheet-only workflow', 'Clear plan limits'],
|
||||
},
|
||||
];
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: 'What does it mean to manage QR codes?',
|
||||
answer:
|
||||
'In QR Master, managing QR codes means using one dashboard to review your QR codes, edit dynamic destinations, download files, and check scan activity instead of tracking everything manually.',
|
||||
},
|
||||
{
|
||||
question: 'Can I edit a QR code after printing it?',
|
||||
answer:
|
||||
'Yes, if it is a dynamic QR code. Static QR codes stay fixed after creation.',
|
||||
},
|
||||
{
|
||||
question: 'How many dynamic QR codes can I manage?',
|
||||
answer:
|
||||
'Free includes 3 dynamic QR codes, Pro includes 50, and Business includes 500. Static QR codes are unlimited.',
|
||||
},
|
||||
{
|
||||
question: 'What analytics are visible today?',
|
||||
answer:
|
||||
'The current dashboard shows total scans, active QR codes, and unique scans. Additional analytics views add more scan context such as time, device, and location.',
|
||||
},
|
||||
{
|
||||
question: 'Does the current product include team roles or API-based QR management?',
|
||||
answer:
|
||||
'This page only reflects the verified current surface: dashboard management, dynamic edits, analytics, downloads, tags, and plan limits. It does not claim team roles or public API-based QR management as current verified capabilities.',
|
||||
},
|
||||
];
|
||||
|
||||
const softwareSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': 'https://www.qrmaster.net/manage-qr-codes#software',
|
||||
name: 'QR Master - QR Code Management Dashboard',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
lowPrice: '0',
|
||||
highPrice: '29',
|
||||
priceCurrency: 'EUR',
|
||||
},
|
||||
featureList: [
|
||||
'Central QR code dashboard',
|
||||
'Edit dynamic QR code destinations',
|
||||
'Review total and unique scan counts',
|
||||
'Download QR codes as PNG or SVG',
|
||||
'Tag QR code records and review active status',
|
||||
'Manage current plan limits for dynamic QR codes',
|
||||
],
|
||||
};
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Manage QR Codes', url: '/manage-qr-codes' },
|
||||
];
|
||||
|
||||
export default function ManageQRCodesPage() {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd
|
||||
data={[
|
||||
softwareSchema,
|
||||
breadcrumbSchema(breadcrumbItems),
|
||||
faqPageSchema(faqItems),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-green-50 via-white to-blue-50 py-20">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
<div className="mt-8 grid items-center gap-12 lg:grid-cols-2">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center rounded-full bg-green-100 px-4 py-2 text-sm font-semibold text-green-800">
|
||||
Dashboard-first QR management
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-5xl font-bold leading-tight text-gray-900 lg:text-6xl">
|
||||
Manage QR Codes from one dashboard
|
||||
</h1>
|
||||
<p className="text-xl leading-relaxed text-gray-600">
|
||||
Review active QR codes, edit dynamic destinations, download files, and
|
||||
monitor scan totals from one place instead of managing printed QR workflows manually.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
'See QR codes in one dashboard',
|
||||
'Edit dynamic destinations after print',
|
||||
'Review total scans, active codes, and unique scans',
|
||||
'Work within the current Free, Pro, and Business dynamic QR limits',
|
||||
].map((feature) => (
|
||||
<div key={feature} className="flex items-center gap-3">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-green-500">
|
||||
<svg className="h-3 w-3 text-white" 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>
|
||||
</div>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="w-full px-8 py-4 text-lg sm:w-auto">
|
||||
Get Started Free
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button variant="outline" size="lg" className="w-full px-8 py-4 text-lg sm:w-auto">
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Card className="p-6 shadow-2xl">
|
||||
<h3 className="mb-4 text-lg font-semibold">Dashboard snapshot</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-700">Active QR codes</span>
|
||||
<span className="text-2xl font-bold text-blue-600">3 / 50 / 500</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Plan-based dynamic QR capacity</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<div className="mb-1 text-xs text-gray-600">Total scans</div>
|
||||
<div className="text-xl font-bold text-green-600">Dashboard metric</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||
<div className="mb-1 text-xs text-gray-600">Unique scans</div>
|
||||
<div className="text-xl font-bold text-purple-600">Dashboard metric</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="mb-2 text-xs text-gray-600">Available actions</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-gray-700">
|
||||
<span className="rounded-full bg-white px-3 py-1">View details</span>
|
||||
<span className="rounded-full bg-white px-3 py-1">Download PNG</span>
|
||||
<span className="rounded-full bg-white px-3 py-1">Download SVG</span>
|
||||
<span className="rounded-full bg-white px-3 py-1">Edit dynamic QR</span>
|
||||
<span className="rounded-full bg-white px-3 py-1">Delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<AnswerFirstBlock
|
||||
whatIsIt="QR Master management is a dashboard workflow for reviewing QR codes, editing dynamic destinations, downloading files, and checking scan activity from one place. It is most useful when your QR program is active enough that single-file handling becomes messy."
|
||||
whenToUse={[
|
||||
'You need one place to review active QR codes and their scan totals',
|
||||
'You want to edit dynamic QR destinations after print without replacing the QR image',
|
||||
'You need to keep static and dynamic QR workflows organized around current plan limits',
|
||||
]}
|
||||
comparison={{
|
||||
leftTitle: 'Manual handling',
|
||||
rightTitle: 'Dashboard management',
|
||||
items: [
|
||||
{ label: 'See active QR codes in one place', value: true, text: 'Scattered across files or exports' },
|
||||
{ label: 'Edit dynamic destinations later', value: true, text: 'Not possible outside dynamic QR management' },
|
||||
{ label: 'Review scan totals and unique scans', value: true, text: 'No unified dashboard view' },
|
||||
],
|
||||
}}
|
||||
howTo={{
|
||||
steps: [
|
||||
'Create or save QR codes into your QR Master account',
|
||||
'Open the dashboard to review active codes, scans, and available actions',
|
||||
'Edit dynamic destinations or download the QR files you need for the next workflow',
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto max-w-5xl px-4 pb-8 sm:px-6 lg:px-8">
|
||||
<FAQSection items={faqItems} title="QR management questions" />
|
||||
</div>
|
||||
|
||||
<section className="bg-gray-50 py-20">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="mb-4 text-4xl font-bold text-gray-900">What the current dashboard supports</h2>
|
||||
<p className="mx-auto max-w-3xl text-xl text-gray-600">
|
||||
These capabilities are tied to the present product surface rather than future or inferred roadmap features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{verifiedCapabilities.map((feature) => (
|
||||
<Card key={feature.title} className="p-6 transition-shadow hover:shadow-lg">
|
||||
<h3 className="mb-2 text-xl font-semibold text-gray-900">{feature.title}</h3>
|
||||
<p className="text-gray-600">{feature.description}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="mb-4 text-4xl font-bold text-gray-900">Where QR management is most useful</h2>
|
||||
<p className="mx-auto max-w-3xl text-xl text-gray-600">
|
||||
Use the dashboard when your QR workflows need ongoing edits, downloads, and visibility instead of one-off creation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{operationalUseCases.map((useCase) => (
|
||||
<Card key={useCase.title} className="p-8">
|
||||
<h3 className="mb-3 text-2xl font-bold text-gray-900">{useCase.title}</h3>
|
||||
<p className="mb-6 text-gray-600">{useCase.description}</p>
|
||||
<ul className="space-y-2">
|
||||
{useCase.points.map((point) => (
|
||||
<li key={point} className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 flex-shrink-0 text-green-500" 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>
|
||||
<span className="text-gray-700">{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-gradient-to-r from-green-600 to-blue-600 py-20 text-white">
|
||||
<div className="container mx-auto max-w-5xl px-4 text-center sm:px-6 lg:px-8">
|
||||
<h2 className="mb-6 text-4xl font-bold">Start managing your QR codes with one account</h2>
|
||||
<p className="mb-8 text-xl text-green-100">
|
||||
Keep dynamic updates, downloads, and scan reporting in one dashboard instead of spreading the workflow across files and ad hoc links.
|
||||
</p>
|
||||
<div className="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Link href="/signup">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
className="w-full bg-white px-8 py-4 text-lg text-green-600 hover:bg-gray-100 sm:w-auto"
|
||||
>
|
||||
Get Started Free
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full border-white px-8 py-4 text-lg text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import PricingClient from './PricingClient';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { AnswerFirstBlock } from '@/components/marketing/AnswerFirstBlock';
|
||||
import { FAQSection } from '@/components/aeo/FAQSection';
|
||||
import { breadcrumbSchema, faqPageSchema } from '@/lib/schema';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Pricing Plans | QR Master',
|
||||
},
|
||||
description:
|
||||
'Compare QR Master pricing plans. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation.',
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/pricing',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Pricing Plans | QR Master',
|
||||
description:
|
||||
'Compare QR Master pricing plans. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation.',
|
||||
url: 'https://www.qrmaster.net/pricing',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master Pricing Plans',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: 'How many dynamic QR codes are included in each plan?',
|
||||
answer:
|
||||
'Free includes 3 active dynamic QR codes. Pro includes 50 dynamic QR codes. Business includes 500 dynamic QR codes.',
|
||||
},
|
||||
{
|
||||
question: 'Do all plans include static QR codes?',
|
||||
answer:
|
||||
'Yes. All plans include unlimited static QR codes.',
|
||||
},
|
||||
{
|
||||
question: 'Which plan includes bulk QR creation?',
|
||||
answer:
|
||||
'Bulk QR creation is included in the Business plan.',
|
||||
},
|
||||
{
|
||||
question: 'Which plans include analytics and branding?',
|
||||
answer:
|
||||
'The Free plan includes basic scan tracking. Pro adds advanced analytics and custom branding. Business includes everything from Pro plus bulk QR creation and priority email support.',
|
||||
},
|
||||
];
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Pricing', url: '/pricing' },
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[breadcrumbSchema(breadcrumbItems), faqPageSchema(faqItems)]} />
|
||||
|
||||
<div className="bg-white">
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<AnswerFirstBlock
|
||||
whatIsIt="QR Master pricing is organized around how many active dynamic QR codes you need and whether you need advanced analytics, branding, or bulk creation. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation."
|
||||
whenToUse={[
|
||||
'Choose Free when you need a small number of active dynamic QR codes and unlimited static QR codes',
|
||||
'Choose Pro when you need more active dynamic QR codes plus advanced analytics and custom branding',
|
||||
'Choose Business when you need 500 active dynamic QR codes and the bulk QR creation workflow',
|
||||
]}
|
||||
comparison={{
|
||||
leftTitle: 'Lower plans',
|
||||
rightTitle: 'Higher plan adds',
|
||||
items: [
|
||||
{ label: 'Dynamic QR capacity', value: true, text: '3 or 50 active codes' },
|
||||
{ label: 'Bulk QR creation', value: true, text: 'Not included before Business' },
|
||||
{ label: 'Advanced analytics and branding', value: true, text: 'Basic or Pro-level only' },
|
||||
],
|
||||
}}
|
||||
howTo={{
|
||||
steps: [
|
||||
'Estimate how many active dynamic QR codes you need at one time',
|
||||
'Decide whether you also need advanced analytics, branding, or bulk creation',
|
||||
'Choose the plan that matches the current workflow instead of paying for unused capacity',
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto max-w-5xl px-4 pb-4 sm:px-6 lg:px-8">
|
||||
<FAQSection items={faqItems} title="Pricing questions" />
|
||||
</div>
|
||||
|
||||
<div className="sr-only">
|
||||
<h2>Compare our plans</h2>
|
||||
<p>
|
||||
Free includes 3 active dynamic QR codes and unlimited static QR codes. Pro
|
||||
includes 50 dynamic QR codes, advanced analytics, and custom branding.
|
||||
Business includes 500 dynamic QR codes, everything from Pro, bulk QR creation
|
||||
up to 1,000, and priority email support.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PricingClient />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import PricingClient from './PricingClient';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { AnswerFirstBlock } from '@/components/marketing/AnswerFirstBlock';
|
||||
import { FAQSection } from '@/components/aeo/FAQSection';
|
||||
import { breadcrumbSchema, faqPageSchema } from '@/lib/schema';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Pricing Plans | QR Master',
|
||||
},
|
||||
description:
|
||||
'Compare QR Master pricing plans. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation.',
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/pricing',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Pricing Plans | QR Master',
|
||||
description:
|
||||
'Compare QR Master pricing plans. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation.',
|
||||
url: 'https://www.qrmaster.net/pricing',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master Pricing Plans',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: 'How many dynamic QR codes are included in each plan?',
|
||||
answer:
|
||||
'Free includes 3 active dynamic QR codes. Pro includes 50 dynamic QR codes. Business includes 500 dynamic QR codes.',
|
||||
},
|
||||
{
|
||||
question: 'Do all plans include static QR codes?',
|
||||
answer:
|
||||
'Yes. All plans include unlimited static QR codes.',
|
||||
},
|
||||
{
|
||||
question: 'Which plan includes bulk QR creation?',
|
||||
answer:
|
||||
'Bulk QR creation is included in the Business plan.',
|
||||
},
|
||||
{
|
||||
question: 'Which plans include analytics and branding?',
|
||||
answer:
|
||||
'The Free plan includes basic scan tracking. Pro adds advanced analytics and custom branding. Business includes everything from Pro plus bulk QR creation and priority email support.',
|
||||
},
|
||||
];
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Pricing', url: '/pricing' },
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[breadcrumbSchema(breadcrumbItems), faqPageSchema(faqItems)]} />
|
||||
|
||||
<div className="bg-white">
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<AnswerFirstBlock
|
||||
whatIsIt="QR Master pricing is organized around how many active dynamic QR codes you need and whether you need advanced analytics, branding, or bulk creation. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation."
|
||||
whenToUse={[
|
||||
'Choose Free when you need a small number of active dynamic QR codes and unlimited static QR codes',
|
||||
'Choose Pro when you need more active dynamic QR codes plus advanced analytics and custom branding',
|
||||
'Choose Business when you need 500 active dynamic QR codes and the bulk QR creation workflow',
|
||||
]}
|
||||
comparison={{
|
||||
leftTitle: 'Lower plans',
|
||||
rightTitle: 'Higher plan adds',
|
||||
items: [
|
||||
{ label: 'Dynamic QR capacity', value: true, text: '3 or 50 active codes' },
|
||||
{ label: 'Bulk QR creation', value: true, text: 'Not included before Business' },
|
||||
{ label: 'Advanced analytics and branding', value: true, text: 'Basic or Pro-level only' },
|
||||
],
|
||||
}}
|
||||
howTo={{
|
||||
steps: [
|
||||
'Estimate how many active dynamic QR codes you need at one time',
|
||||
'Decide whether you also need advanced analytics, branding, or bulk creation',
|
||||
'Choose the plan that matches the current workflow instead of paying for unused capacity',
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto max-w-5xl px-4 pb-4 sm:px-6 lg:px-8">
|
||||
<FAQSection items={faqItems} title="Pricing questions" />
|
||||
</div>
|
||||
|
||||
<div className="sr-only">
|
||||
<h2>Compare our plans</h2>
|
||||
<p>
|
||||
Free includes 3 active dynamic QR codes and unlimited static QR codes. Pro
|
||||
includes 50 dynamic QR codes, advanced analytics, and custom branding.
|
||||
Business includes 500 dynamic QR codes, everything from Pro, bulk QR creation
|
||||
up to 1,000, and priority email support.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PricingClient />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +1,143 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import {
|
||||
buildUseCaseMetadata,
|
||||
UseCasePageTemplate,
|
||||
} from "@/components/marketing/UseCasePageTemplate";
|
||||
|
||||
export const metadata: Metadata = buildUseCaseMetadata({
|
||||
title: "QR Code Analytics",
|
||||
description:
|
||||
"Measure QR code scans by placement, timing, device context, and campaign route so offline workflows become reportable.",
|
||||
canonicalPath: "/qr-code-analytics",
|
||||
});
|
||||
|
||||
const softwareSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": "https://www.qrmaster.net/qr-code-analytics#software",
|
||||
name: "QR Master - QR Code Analytics",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web Browser",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
availability: "https://schema.org/InStock",
|
||||
},
|
||||
description:
|
||||
"QR analytics software for measuring scans by placement, timing, device context, and offline campaign routing.",
|
||||
featureList: [
|
||||
"Placement-level scan reporting",
|
||||
"Device and timing context",
|
||||
"Offline-to-online campaign attribution",
|
||||
"Scan visibility across print workflows",
|
||||
"Destination updates without reprinting",
|
||||
],
|
||||
};
|
||||
|
||||
export default function QRCodeAnalyticsPage() {
|
||||
return (
|
||||
<UseCasePageTemplate
|
||||
title="QR Code Analytics"
|
||||
description="Measure QR code scans by placement, timing, device context, and campaign route so offline workflows become reportable."
|
||||
eyebrow="Analytics"
|
||||
intro="QR code analytics matters when a scan is not just a click, but evidence that a sign, flyer, package, or service prompt is doing its job in the real world."
|
||||
pageType="commercial"
|
||||
cluster="qr-analytics"
|
||||
useCase="qr-analytics"
|
||||
breadcrumbs={[
|
||||
{ name: "Home", url: "/" },
|
||||
{ name: "QR Code Analytics", url: "/qr-code-analytics" },
|
||||
]}
|
||||
answer="QR code analytics helps you understand which printed placements, campaigns, and post-scan routes generate useful activity so you can improve what gets reprinted, redistributed, or scaled next."
|
||||
whenToUse={[
|
||||
"You need more than raw scan counts from campaigns, packaging, or offline placements.",
|
||||
"You want to compare where scans happen and which printed surfaces actually drive action.",
|
||||
"You need a clearer bridge between QR scans and business outcomes such as signup, offers, or support engagement.",
|
||||
]}
|
||||
comparisonItems={[
|
||||
{ label: "Placement visibility", text: "Usually blended", value: true },
|
||||
{ label: "Post-print reporting", text: "Weak", value: true },
|
||||
{ label: "Campaign comparison", text: "Manual or partial", value: true },
|
||||
]}
|
||||
howToSteps={[
|
||||
"Create QR flows that map to real placements or workflow contexts instead of one generic code for every use case.",
|
||||
"Track scans with enough context to compare signs, flyers, inserts, or support surfaces cleanly.",
|
||||
"Use the reporting to decide which destinations, offers, or print placements deserve the next round of investment.",
|
||||
]}
|
||||
primaryCta={{
|
||||
href: "/signup",
|
||||
label: "Start measuring QR scans",
|
||||
}}
|
||||
secondaryCta={{
|
||||
href: "/use-cases",
|
||||
label: "Browse measured workflows",
|
||||
}}
|
||||
workflowTitle="What useful QR analytics should help you answer"
|
||||
workflowIntro="The point of analytics is not to produce dashboards for their own sake. It is to make better decisions about what to print again, where to place it, and what happens after the scan."
|
||||
workflowCards={[
|
||||
{
|
||||
title: "Placement comparison",
|
||||
description:
|
||||
"Separate flyer, packaging, sign, event, or service-surface traffic so you know which printed context actually creates useful scan activity.",
|
||||
},
|
||||
{
|
||||
title: "Post-print flexibility",
|
||||
description:
|
||||
"Review performance and then improve the destination, offer, or next action without replacing every physical code already in circulation.",
|
||||
},
|
||||
{
|
||||
title: "Operational reporting",
|
||||
description:
|
||||
"Give marketing, operations, or support teams a better view of what physical QR programs are doing once they are live in the field.",
|
||||
},
|
||||
]}
|
||||
checklistTitle="QR analytics checklist"
|
||||
checklist={[
|
||||
"Define which placements or workflow surfaces should be compared before launching the QR program.",
|
||||
"Use naming or routing that lets scans be grouped by real business context, not only by one generic campaign.",
|
||||
"Make the first post-scan step relevant enough that a scan can become a useful action, not a bounce.",
|
||||
"Review analytics before reprinting so the next batch reflects real-world performance.",
|
||||
]}
|
||||
supportLinks={[
|
||||
{
|
||||
href: "/use-cases/packaging-qr-codes",
|
||||
title: "Use case: Packaging QR Codes",
|
||||
description:
|
||||
"See how packaging scans can become a measurable post-purchase signal instead of a blind spot.",
|
||||
},
|
||||
{
|
||||
href: "/use-cases/flyer-qr-codes",
|
||||
title: "Use case: Flyer QR Codes",
|
||||
description:
|
||||
"Useful when scan performance needs to be reviewed by distribution point or campaign wave.",
|
||||
},
|
||||
{
|
||||
href: "/blog/trackable-qr-codes",
|
||||
title: "Trackable QR Codes",
|
||||
description:
|
||||
"Support article for understanding what measurable QR setups should capture and why it matters.",
|
||||
},
|
||||
]}
|
||||
faq={[
|
||||
{
|
||||
question: "What can QR code analytics show?",
|
||||
answer:
|
||||
"QR code analytics can show scan activity by placement, time, device context, and campaign route so teams can see which physical programs are actually performing.",
|
||||
},
|
||||
{
|
||||
question: "Why are QR code analytics useful for offline campaigns?",
|
||||
answer:
|
||||
"They help turn offline placements such as flyers, packaging, signs, or event materials into something measurable instead of relying on assumptions about what worked.",
|
||||
},
|
||||
{
|
||||
question: "Do I need dynamic QR codes for analytics?",
|
||||
answer:
|
||||
"In most cases yes, because analytics usually depends on a managed redirect or reporting layer that also lets you update destinations without reprinting.",
|
||||
},
|
||||
]}
|
||||
schemaData={[softwareSchema]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import {
|
||||
buildUseCaseMetadata,
|
||||
UseCasePageTemplate,
|
||||
} from "@/components/marketing/UseCasePageTemplate";
|
||||
|
||||
export const metadata: Metadata = buildUseCaseMetadata({
|
||||
title: "QR Code Analytics",
|
||||
description:
|
||||
"Measure QR code scans by placement, timing, device context, and campaign route so offline workflows become reportable.",
|
||||
canonicalPath: "/qr-code-analytics",
|
||||
});
|
||||
|
||||
const softwareSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": "https://www.qrmaster.net/qr-code-analytics#software",
|
||||
name: "QR Master - QR Code Analytics",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web Browser",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
availability: "https://schema.org/InStock",
|
||||
},
|
||||
description:
|
||||
"QR analytics software for measuring scans by placement, timing, device context, and offline campaign routing.",
|
||||
featureList: [
|
||||
"Placement-level scan reporting",
|
||||
"Device and timing context",
|
||||
"Offline-to-online campaign attribution",
|
||||
"Scan visibility across print workflows",
|
||||
"Destination updates without reprinting",
|
||||
],
|
||||
};
|
||||
|
||||
export default function QRCodeAnalyticsPage() {
|
||||
return (
|
||||
<UseCasePageTemplate
|
||||
title="QR Code Analytics"
|
||||
description="Measure QR code scans by placement, timing, device context, and campaign route so offline workflows become reportable."
|
||||
eyebrow="Analytics"
|
||||
intro="QR code analytics matters when a scan is not just a click, but evidence that a sign, flyer, package, or service prompt is doing its job in the real world."
|
||||
pageType="commercial"
|
||||
cluster="qr-analytics"
|
||||
useCase="qr-analytics"
|
||||
breadcrumbs={[
|
||||
{ name: "Home", url: "/" },
|
||||
{ name: "QR Code Analytics", url: "/qr-code-analytics" },
|
||||
]}
|
||||
answer="QR code analytics helps you understand which printed placements, campaigns, and post-scan routes generate useful activity so you can improve what gets reprinted, redistributed, or scaled next."
|
||||
whenToUse={[
|
||||
"You need more than raw scan counts from campaigns, packaging, or offline placements.",
|
||||
"You want to compare where scans happen and which printed surfaces actually drive action.",
|
||||
"You need a clearer bridge between QR scans and business outcomes such as signup, offers, or support engagement.",
|
||||
]}
|
||||
comparisonItems={[
|
||||
{ label: "Placement visibility", text: "Usually blended", value: true },
|
||||
{ label: "Post-print reporting", text: "Weak", value: true },
|
||||
{ label: "Campaign comparison", text: "Manual or partial", value: true },
|
||||
]}
|
||||
howToSteps={[
|
||||
"Create QR flows that map to real placements or workflow contexts instead of one generic code for every use case.",
|
||||
"Track scans with enough context to compare signs, flyers, inserts, or support surfaces cleanly.",
|
||||
"Use the reporting to decide which destinations, offers, or print placements deserve the next round of investment.",
|
||||
]}
|
||||
primaryCta={{
|
||||
href: "/signup",
|
||||
label: "Start measuring QR scans",
|
||||
}}
|
||||
secondaryCta={{
|
||||
href: "/use-cases",
|
||||
label: "Browse measured workflows",
|
||||
}}
|
||||
workflowTitle="What useful QR analytics should help you answer"
|
||||
workflowIntro="The point of analytics is not to produce dashboards for their own sake. It is to make better decisions about what to print again, where to place it, and what happens after the scan."
|
||||
workflowCards={[
|
||||
{
|
||||
title: "Placement comparison",
|
||||
description:
|
||||
"Separate flyer, packaging, sign, event, or service-surface traffic so you know which printed context actually creates useful scan activity.",
|
||||
},
|
||||
{
|
||||
title: "Post-print flexibility",
|
||||
description:
|
||||
"Review performance and then improve the destination, offer, or next action without replacing every physical code already in circulation.",
|
||||
},
|
||||
{
|
||||
title: "Operational reporting",
|
||||
description:
|
||||
"Give marketing, operations, or support teams a better view of what physical QR programs are doing once they are live in the field.",
|
||||
},
|
||||
]}
|
||||
checklistTitle="QR analytics checklist"
|
||||
checklist={[
|
||||
"Define which placements or workflow surfaces should be compared before launching the QR program.",
|
||||
"Use naming or routing that lets scans be grouped by real business context, not only by one generic campaign.",
|
||||
"Make the first post-scan step relevant enough that a scan can become a useful action, not a bounce.",
|
||||
"Review analytics before reprinting so the next batch reflects real-world performance.",
|
||||
]}
|
||||
supportLinks={[
|
||||
{
|
||||
href: "/use-cases/packaging-qr-codes",
|
||||
title: "Use case: Packaging QR Codes",
|
||||
description:
|
||||
"See how packaging scans can become a measurable post-purchase signal instead of a blind spot.",
|
||||
},
|
||||
{
|
||||
href: "/use-cases/flyer-qr-codes",
|
||||
title: "Use case: Flyer QR Codes",
|
||||
description:
|
||||
"Useful when scan performance needs to be reviewed by distribution point or campaign wave.",
|
||||
},
|
||||
{
|
||||
href: "/blog/trackable-qr-codes",
|
||||
title: "Trackable QR Codes",
|
||||
description:
|
||||
"Support article for understanding what measurable QR setups should capture and why it matters.",
|
||||
},
|
||||
]}
|
||||
faq={[
|
||||
{
|
||||
question: "What can QR code analytics show?",
|
||||
answer:
|
||||
"QR code analytics can show scan activity by placement, time, device context, and campaign route so teams can see which physical programs are actually performing.",
|
||||
},
|
||||
{
|
||||
question: "Why are QR code analytics useful for offline campaigns?",
|
||||
answer:
|
||||
"They help turn offline placements such as flyers, packaging, signs, or event materials into something measurable instead of relying on assumptions about what worked.",
|
||||
},
|
||||
{
|
||||
question: "Do I need dynamic QR codes for analytics?",
|
||||
answer:
|
||||
"In most cases yes, because analytics usually depends on a managed redirect or reporting layer that also lets you update destinations without reprinting.",
|
||||
},
|
||||
]}
|
||||
schemaData={[softwareSchema]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,112 +1,112 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import {
|
||||
buildUseCaseMetadata,
|
||||
UseCasePageTemplate,
|
||||
} from "@/components/marketing/UseCasePageTemplate";
|
||||
|
||||
export const metadata: Metadata = buildUseCaseMetadata({
|
||||
title: "QR Codes for Marketing Campaigns",
|
||||
description:
|
||||
"Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution.",
|
||||
canonicalPath: "/qr-code-for-marketing-campaigns",
|
||||
});
|
||||
|
||||
export default function QRCodeForMarketingCampaignsPage() {
|
||||
return (
|
||||
<UseCasePageTemplate
|
||||
title="QR Codes for Marketing Campaigns"
|
||||
description="Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution."
|
||||
eyebrow="Campaign Workflows"
|
||||
intro="Marketing campaign QR codes work best when the code on the printed asset stays stable while the destination and attribution model can evolve with the campaign."
|
||||
pageType="commercial"
|
||||
cluster="marketing-campaigns"
|
||||
useCase="marketing-campaigns"
|
||||
breadcrumbs={[
|
||||
{ name: "Home", url: "/" },
|
||||
{
|
||||
name: "QR Codes for Marketing Campaigns",
|
||||
url: "/qr-code-for-marketing-campaigns",
|
||||
},
|
||||
]}
|
||||
answer="A campaign QR code should do more than open a page. It should help you compare placements, update the destination when the offer changes, and route offline traffic into a measurable funnel."
|
||||
whenToUse={[
|
||||
"You run flyers, posters, packaging inserts, or event signage with campaign-specific CTA copy.",
|
||||
"You want to compare placements or creatives instead of treating every scan as generic traffic.",
|
||||
"Your destination may change during the life of the printed campaign.",
|
||||
]}
|
||||
comparisonItems={[
|
||||
{ label: "Offer updates", text: "New print required", value: true },
|
||||
{ label: "Placement attribution", text: "Often manual", value: true },
|
||||
{ label: "Creative testing", text: "Hard to manage", value: true },
|
||||
]}
|
||||
howToSteps={[
|
||||
"Create campaign QR flows around one clear action and one named placement context.",
|
||||
"Use dynamic destinations or tagged URLs so the print stays usable when the offer changes.",
|
||||
"Measure scans with a clean CTA path into signup, lead capture, or campaign landing pages.",
|
||||
]}
|
||||
primaryCta={{
|
||||
href: "/dynamic-qr-code-generator",
|
||||
label: "Create a trackable campaign QR",
|
||||
}}
|
||||
secondaryCta={{
|
||||
href: "/use-cases",
|
||||
label: "Browse use-case workflows",
|
||||
}}
|
||||
workflowTitle="What strong campaign QR workflows look like"
|
||||
workflowIntro="Campaign QR strategy becomes more useful when creative, placement, and destination are treated as a system rather than a single link printed everywhere."
|
||||
workflowCards={[
|
||||
{
|
||||
title: "Placement-aware routing",
|
||||
description: "Keep banner, flyer, packaging, and in-store placements comparable by using distinct destinations or campaign tags.",
|
||||
},
|
||||
{
|
||||
title: "Post-print flexibility",
|
||||
description: "Adjust the landing page, offer, or CTA destination after print when the campaign learns something or needs a fast update.",
|
||||
},
|
||||
{
|
||||
title: "Measurement-ready handoff",
|
||||
description: "Push campaign scans toward signup, booking, or lead-gen paths so the QR is tied to a business outcome instead of a vanity click.",
|
||||
},
|
||||
]}
|
||||
checklistTitle="Campaign QR checklist"
|
||||
checklist={[
|
||||
"Match each QR code to one campaign purpose and one primary CTA.",
|
||||
"Differentiate placements with clean naming or URL tagging before the assets go to print.",
|
||||
"Use a destination you can update when the promotion, offer, or landing page changes.",
|
||||
"Link the campaign flow back to a measured CTA path instead of stopping at raw scan counts.",
|
||||
]}
|
||||
supportLinks={[
|
||||
{
|
||||
href: "/qr-code-tracking",
|
||||
title: "QR Code Tracking",
|
||||
description: "Use when the real priority is measuring placement and scanner context.",
|
||||
},
|
||||
{
|
||||
href: "/custom-qr-code-generator",
|
||||
title: "Custom QR Code Generator",
|
||||
description: "Useful when brand fit and print creative need more control.",
|
||||
},
|
||||
{
|
||||
href: "/blog/utm-parameter-qr-codes",
|
||||
title: "UTM Parameters with QR Codes",
|
||||
description: "Support article for placement naming and campaign attribution strategy.",
|
||||
},
|
||||
]}
|
||||
faq={[
|
||||
{
|
||||
question: "Why use QR codes in marketing campaigns?",
|
||||
answer: "Campaign QR codes help move offline audiences into a measurable online path. They are most useful when the destination and tracking setup are planned before the assets go live.",
|
||||
},
|
||||
{
|
||||
question: "Should campaign QR codes be dynamic?",
|
||||
answer: "Yes, when the destination, offer, or campaign landing page may change after print. That avoids replacing materials just because the target page changes.",
|
||||
},
|
||||
{
|
||||
question: "How do I track different QR placements in one campaign?",
|
||||
answer: "Use distinct destinations or tagged URLs for each placement so flyers, posters, booth signs, and packaging inserts can be compared cleanly.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import {
|
||||
buildUseCaseMetadata,
|
||||
UseCasePageTemplate,
|
||||
} from "@/components/marketing/UseCasePageTemplate";
|
||||
|
||||
export const metadata: Metadata = buildUseCaseMetadata({
|
||||
title: "QR Codes for Marketing Campaigns",
|
||||
description:
|
||||
"Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution.",
|
||||
canonicalPath: "/qr-code-for-marketing-campaigns",
|
||||
});
|
||||
|
||||
export default function QRCodeForMarketingCampaignsPage() {
|
||||
return (
|
||||
<UseCasePageTemplate
|
||||
title="QR Codes for Marketing Campaigns"
|
||||
description="Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution."
|
||||
eyebrow="Campaign Workflows"
|
||||
intro="Marketing campaign QR codes work best when the code on the printed asset stays stable while the destination and attribution model can evolve with the campaign."
|
||||
pageType="commercial"
|
||||
cluster="marketing-campaigns"
|
||||
useCase="marketing-campaigns"
|
||||
breadcrumbs={[
|
||||
{ name: "Home", url: "/" },
|
||||
{
|
||||
name: "QR Codes for Marketing Campaigns",
|
||||
url: "/qr-code-for-marketing-campaigns",
|
||||
},
|
||||
]}
|
||||
answer="A campaign QR code should do more than open a page. It should help you compare placements, update the destination when the offer changes, and route offline traffic into a measurable funnel."
|
||||
whenToUse={[
|
||||
"You run flyers, posters, packaging inserts, or event signage with campaign-specific CTA copy.",
|
||||
"You want to compare placements or creatives instead of treating every scan as generic traffic.",
|
||||
"Your destination may change during the life of the printed campaign.",
|
||||
]}
|
||||
comparisonItems={[
|
||||
{ label: "Offer updates", text: "New print required", value: true },
|
||||
{ label: "Placement attribution", text: "Often manual", value: true },
|
||||
{ label: "Creative testing", text: "Hard to manage", value: true },
|
||||
]}
|
||||
howToSteps={[
|
||||
"Create campaign QR flows around one clear action and one named placement context.",
|
||||
"Use dynamic destinations or tagged URLs so the print stays usable when the offer changes.",
|
||||
"Measure scans with a clean CTA path into signup, lead capture, or campaign landing pages.",
|
||||
]}
|
||||
primaryCta={{
|
||||
href: "/dynamic-qr-code-generator",
|
||||
label: "Create a trackable campaign QR",
|
||||
}}
|
||||
secondaryCta={{
|
||||
href: "/use-cases",
|
||||
label: "Browse use-case workflows",
|
||||
}}
|
||||
workflowTitle="What strong campaign QR workflows look like"
|
||||
workflowIntro="Campaign QR strategy becomes more useful when creative, placement, and destination are treated as a system rather than a single link printed everywhere."
|
||||
workflowCards={[
|
||||
{
|
||||
title: "Placement-aware routing",
|
||||
description: "Keep banner, flyer, packaging, and in-store placements comparable by using distinct destinations or campaign tags.",
|
||||
},
|
||||
{
|
||||
title: "Post-print flexibility",
|
||||
description: "Adjust the landing page, offer, or CTA destination after print when the campaign learns something or needs a fast update.",
|
||||
},
|
||||
{
|
||||
title: "Measurement-ready handoff",
|
||||
description: "Push campaign scans toward signup, booking, or lead-gen paths so the QR is tied to a business outcome instead of a vanity click.",
|
||||
},
|
||||
]}
|
||||
checklistTitle="Campaign QR checklist"
|
||||
checklist={[
|
||||
"Match each QR code to one campaign purpose and one primary CTA.",
|
||||
"Differentiate placements with clean naming or URL tagging before the assets go to print.",
|
||||
"Use a destination you can update when the promotion, offer, or landing page changes.",
|
||||
"Link the campaign flow back to a measured CTA path instead of stopping at raw scan counts.",
|
||||
]}
|
||||
supportLinks={[
|
||||
{
|
||||
href: "/qr-code-tracking",
|
||||
title: "QR Code Tracking",
|
||||
description: "Use when the real priority is measuring placement and scanner context.",
|
||||
},
|
||||
{
|
||||
href: "/custom-qr-code-generator",
|
||||
title: "Custom QR Code Generator",
|
||||
description: "Useful when brand fit and print creative need more control.",
|
||||
},
|
||||
{
|
||||
href: "/blog/utm-parameter-qr-codes",
|
||||
title: "UTM Parameters with QR Codes",
|
||||
description: "Support article for placement naming and campaign attribution strategy.",
|
||||
},
|
||||
]}
|
||||
faq={[
|
||||
{
|
||||
question: "Why use QR codes in marketing campaigns?",
|
||||
answer: "Campaign QR codes help move offline audiences into a measurable online path. They are most useful when the destination and tracking setup are planned before the assets go live.",
|
||||
},
|
||||
{
|
||||
question: "Should campaign QR codes be dynamic?",
|
||||
answer: "Yes, when the destination, offer, or campaign landing page may change after print. That avoids replacing materials just because the target page changes.",
|
||||
},
|
||||
{
|
||||
question: "How do I track different QR placements in one campaign?",
|
||||
answer: "Use distinct destinations or tagged URLs for each placement so flyers, posters, booth signs, and packaging inserts can be compared cleanly.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +1,143 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Metadata } from 'next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { organizationSchema, reviewSchema, aggregateRatingSchema } from '@/lib/schema';
|
||||
import { testimonials, getAggregateRating } from '@/lib/testimonial-data';
|
||||
import { Testimonials } from '@/components/marketing/Testimonials';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
function truncateAtWord(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const truncated = text.slice(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('Customer Testimonials | QR Master Reviews', 60);
|
||||
const description = truncateAtWord(
|
||||
'Read what our customers say about QR Master. Real reviews from businesses using dynamic QR codes for restaurants, pottery, retail, events, and more.',
|
||||
160
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords: ['qr master reviews', 'qr code testimonials', 'customer reviews', 'qr code generator reviews', 'dynamic qr code reviews'],
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/testimonials',
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: 'https://www.qrmaster.net/testimonials',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master Customer Testimonials',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function TestimonialsPage() {
|
||||
const aggregateRating = getAggregateRating();
|
||||
const reviewSchemas = testimonials.map(t => reviewSchema(t));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[
|
||||
organizationSchema(),
|
||||
aggregateRatingSchema(aggregateRating),
|
||||
...reviewSchemas
|
||||
]} />
|
||||
|
||||
<div className="bg-white">
|
||||
{/* Hero Section with Aggregate Rating */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20 sm:py-24">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl text-center">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-6">
|
||||
Customer <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">Testimonials</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8 leading-relaxed">
|
||||
Real experiences from businesses using QR Master to create dynamic QR codes
|
||||
</p>
|
||||
|
||||
{/* Aggregate Rating Display */}
|
||||
<div className="flex flex-col items-center justify-center gap-3 mb-10">
|
||||
<div className="flex gap-1" aria-label={`${aggregateRating.ratingValue} out of 5 stars`}>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`w-8 h-8 ${index < aggregateRating.ratingValue
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'fill-gray-200 text-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-lg text-gray-700">
|
||||
<span className="font-bold text-2xl">{aggregateRating.ratingValue}</span> out of 5 stars
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Based on {aggregateRating.reviewCount} {aggregateRating.reviewCount === 1 ? 'review' : 'reviews'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="text-lg px-8 py-6 shadow-lg shadow-blue-500/25">
|
||||
Get Started Free
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials Grid */}
|
||||
<Testimonials
|
||||
testimonials={testimonials}
|
||||
showAll={true}
|
||||
title="What Our Customers Are Saying"
|
||||
subtitle="Discover how businesses use QR Master for their unique needs"
|
||||
/>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-6">
|
||||
Ready to create your own QR codes?
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-10 max-w-2xl mx-auto">
|
||||
Join businesses using QR Master to create dynamic, trackable QR codes for their products, menus, events, and campaigns.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="text-lg px-8 py-6">
|
||||
Start Free Today
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button variant="outline" size="lg" className="text-lg px-8 py-6">
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Metadata } from 'next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { organizationSchema, reviewSchema, aggregateRatingSchema } from '@/lib/schema';
|
||||
import { testimonials, getAggregateRating } from '@/lib/testimonial-data';
|
||||
import { Testimonials } from '@/components/marketing/Testimonials';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
function truncateAtWord(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const truncated = text.slice(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('Customer Testimonials | QR Master Reviews', 60);
|
||||
const description = truncateAtWord(
|
||||
'Read what our customers say about QR Master. Real reviews from businesses using dynamic QR codes for restaurants, pottery, retail, events, and more.',
|
||||
160
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords: ['qr master reviews', 'qr code testimonials', 'customer reviews', 'qr code generator reviews', 'dynamic qr code reviews'],
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/testimonials',
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: 'https://www.qrmaster.net/testimonials',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master Customer Testimonials',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function TestimonialsPage() {
|
||||
const aggregateRating = getAggregateRating();
|
||||
const reviewSchemas = testimonials.map(t => reviewSchema(t));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[
|
||||
organizationSchema(),
|
||||
aggregateRatingSchema(aggregateRating),
|
||||
...reviewSchemas
|
||||
]} />
|
||||
|
||||
<div className="bg-white">
|
||||
{/* Hero Section with Aggregate Rating */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20 sm:py-24">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl text-center">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-6">
|
||||
Customer <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">Testimonials</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8 leading-relaxed">
|
||||
Real experiences from businesses using QR Master to create dynamic QR codes
|
||||
</p>
|
||||
|
||||
{/* Aggregate Rating Display */}
|
||||
<div className="flex flex-col items-center justify-center gap-3 mb-10">
|
||||
<div className="flex gap-1" aria-label={`${aggregateRating.ratingValue} out of 5 stars`}>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`w-8 h-8 ${index < aggregateRating.ratingValue
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'fill-gray-200 text-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-lg text-gray-700">
|
||||
<span className="font-bold text-2xl">{aggregateRating.ratingValue}</span> out of 5 stars
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Based on {aggregateRating.reviewCount} {aggregateRating.reviewCount === 1 ? 'review' : 'reviews'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="text-lg px-8 py-6 shadow-lg shadow-blue-500/25">
|
||||
Get Started Free
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials Grid */}
|
||||
<Testimonials
|
||||
testimonials={testimonials}
|
||||
showAll={true}
|
||||
title="What Our Customers Are Saying"
|
||||
subtitle="Discover how businesses use QR Master for their unique needs"
|
||||
/>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-6">
|
||||
Ready to create your own QR codes?
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-10 max-w-2xl mx-auto">
|
||||
Join businesses using QR Master to create dynamic, trackable QR codes for their products, menus, events, and campaigns.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="text-lg px-8 py-6">
|
||||
Start Free Today
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button variant="outline" size="lg" className="text-lg px-8 py-6">
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type Step = 'use-case' | 'region' | 'result';
|
||||
|
||||
type Result = {
|
||||
format: string;
|
||||
label: string;
|
||||
description: string;
|
||||
example: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const RESULTS: Record<string, Result> = {
|
||||
'ean13': {
|
||||
format: 'EAN-13',
|
||||
label: 'EAN-13',
|
||||
description: 'The global retail standard. Used on consumer products sold in supermarkets, pharmacies, and online shops worldwide.',
|
||||
example: '4006381333931 (a common product barcode)',
|
||||
color: 'blue',
|
||||
},
|
||||
'upca': {
|
||||
format: 'UPC-A',
|
||||
label: 'UPC-A',
|
||||
description: 'The North American retail standard. Functionally equivalent to EAN-13 but with 12 digits. Required by US and Canadian retailers.',
|
||||
example: '012345678905',
|
||||
color: 'indigo',
|
||||
},
|
||||
'code128': {
|
||||
format: 'Code 128',
|
||||
label: 'Code 128',
|
||||
description: 'The most versatile barcode. Supports letters, numbers, and special characters. Used in shipping labels, inventory systems, and internal tracking.',
|
||||
example: 'SHIP-2026-ABC-001',
|
||||
color: 'emerald',
|
||||
},
|
||||
'code39': {
|
||||
format: 'Code 39',
|
||||
label: 'Code 39',
|
||||
description: 'A legacy alphanumeric format still widely used in automotive, defense, and industrial environments. Simpler than Code 128 but less compact.',
|
||||
example: 'PART-7734-A',
|
||||
color: 'orange',
|
||||
},
|
||||
'msi': {
|
||||
format: 'MSI',
|
||||
label: 'MSI',
|
||||
description: 'Designed for inventory and shelf labeling in retail warehouses. Numeric-only. Used for bin locations, shelf tags, and stockroom management.',
|
||||
example: '123456',
|
||||
color: 'purple',
|
||||
},
|
||||
'pharmacode': {
|
||||
format: 'Pharmacode',
|
||||
label: 'Pharmacode',
|
||||
description: 'A pharmaceutical packaging standard used to verify correct product packaging. Encodes a single numeric value (3–131071).',
|
||||
example: '12345',
|
||||
color: 'red',
|
||||
},
|
||||
};
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
blue: 'bg-blue-50 border-blue-300 text-blue-900',
|
||||
indigo: 'bg-indigo-50 border-indigo-300 text-indigo-900',
|
||||
emerald: 'bg-emerald-50 border-emerald-300 text-emerald-900',
|
||||
orange: 'bg-orange-50 border-orange-300 text-orange-900',
|
||||
purple: 'bg-purple-50 border-purple-300 text-purple-900',
|
||||
red: 'bg-red-50 border-red-300 text-red-900',
|
||||
};
|
||||
|
||||
const badgeMap: Record<string, string> = {
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
indigo: 'bg-indigo-100 text-indigo-800',
|
||||
emerald: 'bg-emerald-100 text-emerald-800',
|
||||
orange: 'bg-orange-100 text-orange-800',
|
||||
purple: 'bg-purple-100 text-purple-800',
|
||||
red: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export function BarcodeFormatPicker() {
|
||||
const [step, setStep] = useState<Step>('use-case');
|
||||
const [useCase, setUseCase] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const selectUseCase = (value: string) => {
|
||||
setUseCase(value);
|
||||
if (value === 'retail') {
|
||||
setStep('region');
|
||||
} else {
|
||||
const map: Record<string, string> = {
|
||||
logistics: 'code128',
|
||||
inventory: 'code128',
|
||||
industrial: 'code39',
|
||||
warehouse: 'msi',
|
||||
pharma: 'pharmacode',
|
||||
};
|
||||
setResult(map[value] ?? 'code128');
|
||||
setStep('result');
|
||||
}
|
||||
};
|
||||
|
||||
const selectRegion = (region: string) => {
|
||||
setResult(region === 'us' ? 'upca' : 'ean13');
|
||||
setStep('result');
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setStep('use-case');
|
||||
setUseCase('');
|
||||
setResult('');
|
||||
};
|
||||
|
||||
const res = result ? RESULTS[result] : null;
|
||||
|
||||
return (
|
||||
<div className="not-prose my-8 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-1">Which barcode format do I need?</h3>
|
||||
<p className="text-sm text-slate-500 mb-5">Answer two quick questions to find the right format for your use case.</p>
|
||||
|
||||
{step === 'use-case' && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700 mb-3">What will you use the barcode for?</p>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: 'retail', label: 'Retail products', sub: 'Selling in stores or online' },
|
||||
{ value: 'logistics', label: 'Shipping & logistics', sub: 'Parcel labels, supply chain' },
|
||||
{ value: 'inventory', label: 'Inventory tracking', sub: 'Internal stock management' },
|
||||
{ value: 'industrial', label: 'Industrial / automotive', sub: 'Manufacturing, defense' },
|
||||
{ value: 'warehouse', label: 'Shelf & bin labeling', sub: 'Warehouse locations' },
|
||||
{ value: 'pharma', label: 'Pharmaceutical packaging', sub: 'Medication packaging control' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => selectUseCase(opt.value)}
|
||||
className="text-left rounded-xl border border-slate-200 bg-white p-4 hover:border-blue-400 hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<div className="font-semibold text-slate-900 group-hover:text-blue-800 text-sm">{opt.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{opt.sub}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'region' && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700 mb-3">Where will you primarily sell?</p>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => selectRegion('eu')}
|
||||
className="text-left rounded-xl border border-slate-200 bg-white p-4 hover:border-blue-400 hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<div className="font-semibold text-slate-900 group-hover:text-blue-800 text-sm">Europe / International</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">EU, UK, Asia, global retail</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectRegion('us')}
|
||||
className="text-left rounded-xl border border-slate-200 bg-white p-4 hover:border-blue-400 hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<div className="font-semibold text-slate-900 group-hover:text-blue-800 text-sm">USA / Canada</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">North American retail market</div>
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={reset} className="mt-3 text-xs text-slate-400 hover:text-slate-600 underline">← Start over</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'result' && res && (
|
||||
<div className={`rounded-xl border-2 p-5 ${colorMap[res.color]}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-full ${badgeMap[res.color]}`}>Recommended</span>
|
||||
<span className="font-bold text-xl">{res.label}</span>
|
||||
</div>
|
||||
<p className="text-sm mb-2">{res.description}</p>
|
||||
<p className="text-xs opacity-70 font-mono">Example: {res.example}</p>
|
||||
<button onClick={reset} className="mt-4 text-xs underline opacity-60 hover:opacity-90">← Try again</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BookOpen, CheckCircle, HelpCircle, Layers, Settings, ShoppingCart, Tag, Activity, Factory } from 'lucide-react';
|
||||
import { BookOpen, CheckCircle, Layers, Settings, ShoppingCart, Tag, Activity, Factory } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { FAQSection } from '@/components/aeo/FAQSection';
|
||||
import { BarcodeFormatPicker } from './BarcodeFormatPicker';
|
||||
|
||||
export function BarcodeGuide() {
|
||||
return (
|
||||
@@ -87,100 +89,59 @@ export function BarcodeGuide() {
|
||||
Different barcode formats are used for different purposes. Choosing the right one is important for compatibility and scanning accuracy.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 not-prose my-8">
|
||||
{/* EAN-13 Card */}
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Tag className="w-5 h-5 text-blue-500" />
|
||||
<h4 className="text-lg font-bold text-slate-900 m-0">EAN-13</h4>
|
||||
</div>
|
||||
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Retail • Europe</div>
|
||||
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||
<img src="/barcode-generator-preview.png" alt="EAN-13 Barcode Sample for International Products" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 m-0">
|
||||
EAN-13 is widely used in retail, especially in Europe. It is designed for consumer products sold in stores and supermarkets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* UPC-A Card */}
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ShoppingCart className="w-5 h-5 text-indigo-500" />
|
||||
<h4 className="text-lg font-bold text-slate-900 m-0">UPC-A</h4>
|
||||
</div>
|
||||
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Retail • USA/Canada</div>
|
||||
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||
<img src="/barcode-generator-preview.png" alt="UPC-A Barcode Example for Retail Products in USA" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 m-0">
|
||||
UPC-A is similar to EAN-13 but is mainly used in the United States and Canada for retail products.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Code 128 Card */}
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Settings className="w-5 h-5 text-emerald-500" />
|
||||
<h4 className="text-lg font-bold text-slate-900 m-0">Code 128</h4>
|
||||
</div>
|
||||
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Logistics • Universal</div>
|
||||
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||
<img src="/barcode-generator-preview.png" alt="Code 128 Barcode for Inventory and Shipping Labels" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 m-0">
|
||||
Code 128 is a flexible barcode format that supports letters and numbers. It is commonly used in logistics, shipping, and internal tracking systems.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Code 39 Card */}
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Factory className="w-5 h-5 text-orange-500" />
|
||||
<h4 className="text-lg font-bold text-slate-900 m-0">Code 39</h4>
|
||||
</div>
|
||||
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Industrial • Military</div>
|
||||
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||
<img src="/barcode-generator-preview.png" alt="Code 39 Barcode for Industrial Use" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 m-0">
|
||||
The first alphanumeric barcode, Code 39 is still widely used in automotive and defense industries. It supports numbers and uppercase letters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* MSI Card */}
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Layers className="w-5 h-5 text-purple-500" />
|
||||
<h4 className="text-lg font-bold text-slate-900 m-0">MSI</h4>
|
||||
</div>
|
||||
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Inventory • Shelves</div>
|
||||
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||
<img src="/barcode-generator-preview.png" alt="MSI Barcode for Inventory Management" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 m-0">
|
||||
MSI (Modified Plessey) is often used for inventory control in retail environments, such as labeling shelves in supermarkets and warehouses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pharmacode Card */}
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Activity className="w-5 h-5 text-red-500" />
|
||||
<h4 className="text-lg font-bold text-slate-900 m-0">Pharmacode</h4>
|
||||
</div>
|
||||
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Pharma • Packaging</div>
|
||||
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||
<img src="/barcode-generator-preview.png" alt="Pharmacode for Pharmaceutical Packaging" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 m-0">
|
||||
Pharmacode is a specialized barcode standard used in the pharmaceutical industry for packaging control to prevent medication errors.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="not-prose overflow-x-auto my-8">
|
||||
<table className="w-full text-sm border-collapse rounded-xl overflow-hidden border border-slate-200">
|
||||
<thead>
|
||||
<tr className="bg-slate-900 text-white">
|
||||
<th className="text-left p-3 font-semibold">Format</th>
|
||||
<th className="text-left p-3 font-semibold">Use Case</th>
|
||||
<th className="text-left p-3 font-semibold">Digits / Chars</th>
|
||||
<th className="text-left p-3 font-semibold">Region</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-t border-slate-100 bg-white">
|
||||
<td className="p-3 font-bold text-blue-700">EAN-13</td>
|
||||
<td className="p-3 text-slate-600">Retail products, supermarkets</td>
|
||||
<td className="p-3 text-slate-500 font-mono text-xs">13 numeric</td>
|
||||
<td className="p-3 text-slate-600">Europe / Global</td>
|
||||
</tr>
|
||||
<tr className="border-t border-slate-100 bg-slate-50">
|
||||
<td className="p-3 font-bold text-indigo-700">UPC-A</td>
|
||||
<td className="p-3 text-slate-600">Retail products (North America)</td>
|
||||
<td className="p-3 text-slate-500 font-mono text-xs">12 numeric</td>
|
||||
<td className="p-3 text-slate-600">USA / Canada</td>
|
||||
</tr>
|
||||
<tr className="border-t border-slate-100 bg-white">
|
||||
<td className="p-3 font-bold text-emerald-700">Code 128</td>
|
||||
<td className="p-3 text-slate-600">Shipping, logistics, inventory</td>
|
||||
<td className="p-3 text-slate-500 font-mono text-xs">Variable alphanumeric</td>
|
||||
<td className="p-3 text-slate-600">Universal</td>
|
||||
</tr>
|
||||
<tr className="border-t border-slate-100 bg-slate-50">
|
||||
<td className="p-3 font-bold text-orange-700">Code 39</td>
|
||||
<td className="p-3 text-slate-600">Industrial, automotive, defense</td>
|
||||
<td className="p-3 text-slate-500 font-mono text-xs">Variable alphanumeric</td>
|
||||
<td className="p-3 text-slate-600">Industrial</td>
|
||||
</tr>
|
||||
<tr className="border-t border-slate-100 bg-white">
|
||||
<td className="p-3 font-bold text-purple-700">MSI</td>
|
||||
<td className="p-3 text-slate-600">Shelf / bin labeling, warehouse</td>
|
||||
<td className="p-3 text-slate-500 font-mono text-xs">Variable numeric</td>
|
||||
<td className="p-3 text-slate-600">Retail / Warehouse</td>
|
||||
</tr>
|
||||
<tr className="border-t border-slate-100 bg-slate-50">
|
||||
<td className="p-3 font-bold text-red-700">Pharmacode</td>
|
||||
<td className="p-3 text-slate-600">Pharmaceutical packaging</td>
|
||||
<td className="p-3 text-slate-500 font-mono text-xs">3–131071 numeric</td>
|
||||
<td className="p-3 text-slate-600">Pharma</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<BarcodeFormatPicker />
|
||||
|
||||
<h2>Why Use a Barcode Generator?</h2>
|
||||
<p>Using a Barcode Generator offers several advantages:</p>
|
||||
<div className="not-prose grid gap-4 mb-8">
|
||||
@@ -244,8 +205,20 @@ export function BarcodeGuide() {
|
||||
</ul>
|
||||
<p>
|
||||
A reliable <strong>Barcode Generator</strong> helps streamline these processes and improves efficiency.
|
||||
For tracking QR codes alongside your barcodes, see our <Link href="/blog/qr-code-tracking-guide-2025" className="text-blue-600 hover:underline">QR code tracking guide</Link>.
|
||||
</p>
|
||||
|
||||
<h2>Barcode Generator for Amazon Sellers</h2>
|
||||
<p>
|
||||
If you sell on Amazon, you will encounter two types of barcodes: <strong>GTINs</strong> (Global Trade Item Numbers, such as EAN-13 or UPC-A) required by Amazon to list products, and <strong>FNSKU</strong> barcodes that Amazon assigns to your specific seller account for FBA fulfillment.
|
||||
</p>
|
||||
<p>
|
||||
Our barcode generator can create the <em>image</em> of an EAN-13 or UPC-A barcode if you already have a valid number. However, it cannot issue official GS1-registered numbers. To sell on Amazon with retail barcodes, you need to obtain a legitimate EAN or UPC number from <strong>GS1</strong> (the official barcode standards organization). Purchasing unofficial or recycled barcodes from third-party resellers often leads to listing suppression on Amazon.
|
||||
</p>
|
||||
<div className="not-prose bg-amber-50 border border-amber-200 rounded-xl p-4 my-4 text-sm text-amber-900">
|
||||
<strong>Important for Amazon sellers:</strong> GS1 is the only authorized source for EAN/UPC numbers recognized by Amazon. Visit <strong>gs1.org</strong> to purchase official barcodes for your products. Use this generator to create barcode images once you have a valid number.
|
||||
</div>
|
||||
|
||||
<h2>Understanding Check Digits</h2>
|
||||
<p>
|
||||
Most barcodes (like EAN and UPC) include a "Check Digit"—the last number in the sequence. This digit is calculated mathematically from the other numbers to ensure the barcode is scanned correctly. Even if a barcode is slightly damaged or scratched, the scanner uses the check digit to verify the integrity of the data.
|
||||
@@ -264,48 +237,44 @@ export function BarcodeGuide() {
|
||||
|
||||
<hr className="my-12 border-slate-200" />
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 not-prose">
|
||||
<HelpCircle className="w-6 h-6 text-blue-500" />
|
||||
<h2 className="text-2xl font-bold text-slate-900 m-0">Frequently Asked Questions (FAQ)</h2>
|
||||
</div>
|
||||
|
||||
<div className="not-prose space-y-8">
|
||||
<div>
|
||||
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ What is a Barcode Generator?</h5>
|
||||
<p className="text-slate-600">A Barcode Generator is an online tool that converts numbers or text into scannable barcode images that can be used for products, labels, and inventory systems.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Is this barcode generator free to use?</h5>
|
||||
<p className="text-slate-600">Yes, our online barcode generator is completely free to use with no hidden costs or sign-ups required. You can generate, download, and print barcodes instantly.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Which barcode format should I use?</h5>
|
||||
<p className="text-slate-600">
|
||||
<strong>EAN-13:</strong> Standard for retail products in Europe and globally.<br />
|
||||
<strong>UPC-A:</strong> Standard for retail products in USA/Canada.<br />
|
||||
<strong>Code 128:</strong> Best for logistics, shipping, and internal tracking (supports letters & numbers).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Can I download barcodes in vector format (SVG)?</h5>
|
||||
<p className="text-slate-600">Yes! We offer <strong>SVG downloads</strong>. SVG files are vector-based, meaning they can be scaled to any size without losing quality—perfect for professional product packaging.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ How do I generate a barcode online?</h5>
|
||||
<p className="text-slate-600">To generate a barcode online, enter your product number or text, select the desired barcode format (such as EAN-13 or Code 128), and click the generate button. The barcode will be created instantly.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Are generated barcodes scannable?</h5>
|
||||
<p className="text-slate-600">Yes, barcodes generated with a proper barcode generator are fully scannable. We generate standard-compliant barcodes readable by any standard optical or laser barcode scanner.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Can I use these barcodes for Amazon (EAN/UPC)?</h5>
|
||||
<p className="text-slate-600">You can generate the <em>image</em> for Amazon here if you already have your EAN/UPC number. However, you cannot "create" a valid global EAN number here—you must purchase those official numbers from GS1 to sell on major platforms like Amazon.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ What is the difference between a barcode and a QR code?</h5>
|
||||
<p className="text-slate-600">A barcode stores data horizontally (1D) and is mainly used for product IDs. A QR code stores data in 2D (matrix) and can hold much more information, such as URLs, vCards, or WiFi credentials.</p>
|
||||
</div>
|
||||
<div className="not-prose">
|
||||
<FAQSection
|
||||
title="Frequently Asked Questions"
|
||||
items={[
|
||||
{
|
||||
question: 'What is a Barcode Generator?',
|
||||
answer: 'A Barcode Generator is an online tool that converts numbers or text into scannable barcode images that can be used for products, labels, and inventory systems.',
|
||||
},
|
||||
{
|
||||
question: 'Is this barcode generator free to use?',
|
||||
answer: 'Yes, our online barcode generator is completely free to use with no hidden costs or sign-ups required. You can generate, download, and print barcodes instantly.',
|
||||
},
|
||||
{
|
||||
question: 'Which barcode format should I use?',
|
||||
answer: '<strong>EAN-13</strong> is the standard for retail products in Europe and globally. <strong>UPC-A</strong> is the standard for retail products in USA/Canada. <strong>Code 128</strong> is best for logistics, shipping, and internal tracking as it supports both letters and numbers. Use the format picker above to find the right one for your use case.',
|
||||
},
|
||||
{
|
||||
question: 'Can I download barcodes in vector format (SVG)?',
|
||||
answer: 'Yes — SVG downloads are available. SVG files are vector-based, meaning they can be scaled to any size without losing quality. This is ideal for professional product packaging and labels.',
|
||||
},
|
||||
{
|
||||
question: 'How do I generate a barcode online?',
|
||||
answer: 'Enter your product number or text, select the desired barcode format (such as EAN-13 or Code 128), and the barcode is generated instantly. You can then download it as PNG or SVG.',
|
||||
},
|
||||
{
|
||||
question: 'Are generated barcodes scannable?',
|
||||
answer: 'Yes. We generate standard-compliant barcodes that are readable by any standard optical or laser barcode scanner, including smartphone camera apps.',
|
||||
},
|
||||
{
|
||||
question: 'Can I use these barcodes for Amazon (EAN/UPC)?',
|
||||
answer: 'You can generate the barcode <em>image</em> here if you already have a valid EAN/UPC number. However, you cannot create a globally registered EAN/UPC number here — you must purchase official numbers from GS1 to list products on Amazon or in major retail systems.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between a barcode and a QR code?',
|
||||
answer: 'A barcode stores data in one dimension (horizontal bars) and is mainly used for product identification. A QR code stores data in two dimensions (a matrix) and can hold much more information — URLs, contact details, WiFi credentials, and more.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 p-6 bg-slate-900 rounded-xl text-white not-prose">
|
||||
|
||||
@@ -11,9 +11,9 @@ import { generateSoftwareAppSchema, generateFaqSchema } from '@/lib/schema-utils
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Barcode Generator – Create Barcodes Online for Free',
|
||||
absolute: 'Free Barcode Generator Online – EAN, UPC, Code 128',
|
||||
},
|
||||
description: 'Use a free Barcode Generator to create scannable barcodes online. Supports EAN, UPC and Code 128 for products, labels and inventory.',
|
||||
description: 'Free online barcode generator. Create EAN-13, UPC-A and Code 128 barcodes instantly. Download PNG or SVG. No signup required.',
|
||||
keywords: ['barcode generator', 'online barcode maker', 'create barcode free', 'ean-13 generator', 'upc-a generator', 'code 128 generator', 'barcode creator', 'printable barcodes'],
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/tools/barcode-generator',
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import {
|
||||
buildUseCaseMetadata,
|
||||
UseCasePageTemplate,
|
||||
} from "@/components/marketing/UseCasePageTemplate";
|
||||
import {
|
||||
allUseCases,
|
||||
getUseCasePage,
|
||||
} from "@/lib/growth-pages";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return allUseCases.map((item) => ({
|
||||
slug: item.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export function generateMetadata({ params }: { params: { slug: string } }) {
|
||||
const page = getUseCasePage(params.slug);
|
||||
|
||||
if (!page) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return buildUseCaseMetadata({
|
||||
title: page.title,
|
||||
description: page.metaDescription,
|
||||
canonicalPath: page.href,
|
||||
});
|
||||
}
|
||||
|
||||
export default function UseCaseDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
const page = getUseCasePage(params.slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<UseCasePageTemplate
|
||||
title={page.title}
|
||||
description={page.metaDescription}
|
||||
eyebrow={page.eyebrow}
|
||||
intro={page.intro}
|
||||
pageType="use_case"
|
||||
cluster={page.cluster}
|
||||
useCase={page.slug}
|
||||
breadcrumbs={[
|
||||
{ name: "Home", url: "/" },
|
||||
{ name: "Use Cases", url: "/use-cases" },
|
||||
{ name: page.title, url: page.href },
|
||||
]}
|
||||
answer={page.answer}
|
||||
whenToUse={page.whenToUse}
|
||||
comparisonItems={page.comparisonItems}
|
||||
howToSteps={page.howToSteps}
|
||||
primaryCta={{
|
||||
href: page.parentHref,
|
||||
label: page.ctaLabel,
|
||||
}}
|
||||
secondaryCta={{
|
||||
href: "/use-cases",
|
||||
label: "Explore more use cases",
|
||||
}}
|
||||
workflowTitle={page.workflowTitle}
|
||||
workflowIntro={page.workflowIntro}
|
||||
workflowCards={page.workflowCards}
|
||||
checklistTitle={page.checklistTitle}
|
||||
checklist={page.checklist}
|
||||
supportLinks={page.supportLinks}
|
||||
faq={page.faq}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import {
|
||||
buildUseCaseMetadata,
|
||||
UseCasePageTemplate,
|
||||
} from "@/components/marketing/UseCasePageTemplate";
|
||||
import {
|
||||
allUseCases,
|
||||
getUseCasePage,
|
||||
} from "@/lib/growth-pages";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return allUseCases.map((item) => ({
|
||||
slug: item.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export function generateMetadata({ params }: { params: { slug: string } }) {
|
||||
const page = getUseCasePage(params.slug);
|
||||
|
||||
if (!page) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return buildUseCaseMetadata({
|
||||
title: page.title,
|
||||
description: page.metaDescription,
|
||||
canonicalPath: page.href,
|
||||
});
|
||||
}
|
||||
|
||||
export default function UseCaseDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
const page = getUseCasePage(params.slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<UseCasePageTemplate
|
||||
title={page.title}
|
||||
description={page.metaDescription}
|
||||
eyebrow={page.eyebrow}
|
||||
intro={page.intro}
|
||||
pageType="use_case"
|
||||
cluster={page.cluster}
|
||||
useCase={page.slug}
|
||||
breadcrumbs={[
|
||||
{ name: "Home", url: "/" },
|
||||
{ name: "Use Cases", url: "/use-cases" },
|
||||
{ name: page.title, url: page.href },
|
||||
]}
|
||||
answer={page.answer}
|
||||
whenToUse={page.whenToUse}
|
||||
comparisonItems={page.comparisonItems}
|
||||
howToSteps={page.howToSteps}
|
||||
primaryCta={{
|
||||
href: page.parentHref,
|
||||
label: page.ctaLabel,
|
||||
}}
|
||||
secondaryCta={{
|
||||
href: "/use-cases",
|
||||
label: "Explore more use cases",
|
||||
}}
|
||||
workflowTitle={page.workflowTitle}
|
||||
workflowIntro={page.workflowIntro}
|
||||
workflowCards={page.workflowCards}
|
||||
checklistTitle={page.checklistTitle}
|
||||
checklist={page.checklist}
|
||||
supportLinks={page.supportLinks}
|
||||
faq={page.faq}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,304 +1,304 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
Compass,
|
||||
LibraryBig,
|
||||
Link2,
|
||||
Route,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs, { BreadcrumbItem } from "@/components/Breadcrumbs";
|
||||
import SeoJsonLd from "@/components/SeoJsonLd";
|
||||
import {
|
||||
MarketingPageTracker,
|
||||
TrackedCtaLink,
|
||||
} from "@/components/marketing/MarketingAnalytics";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import {
|
||||
allUseCases,
|
||||
commercialPages,
|
||||
featuredUseCases,
|
||||
supportResources,
|
||||
upcomingUseCaseIdeas,
|
||||
} from "@/lib/growth-pages";
|
||||
import { breadcrumbSchema } from "@/lib/schema";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: "QR Code Use Cases for Business | QR Master",
|
||||
},
|
||||
description:
|
||||
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
|
||||
alternates: {
|
||||
canonical: "https://www.qrmaster.net/use-cases",
|
||||
languages: {
|
||||
"x-default": "https://www.qrmaster.net/use-cases",
|
||||
en: "https://www.qrmaster.net/use-cases",
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: "QR Code Use Cases for Business | QR Master",
|
||||
description:
|
||||
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
|
||||
url: "https://www.qrmaster.net/use-cases",
|
||||
type: "website",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
twitter: {
|
||||
title: "QR Code Use Cases for Business | QR Master",
|
||||
description:
|
||||
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function UseCasesHubPage() {
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{ name: "Home", url: "/" },
|
||||
{ name: "Use Cases", url: "/use-cases" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[breadcrumbSchema(breadcrumbItems)]} />
|
||||
<MarketingPageTracker pageType="use_case_hub" cluster="all-use-cases" />
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-950 text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_34%),radial-gradient(circle_at_right,rgba(255,255,255,0.06),transparent_28%)]" />
|
||||
<div className="relative container mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
|
||||
<Breadcrumbs
|
||||
items={breadcrumbItems}
|
||||
className="[&_a]:text-blue-100/80 [&_a:hover]:text-white [&_span]:text-blue-100/80 [&_[aria-current=page]]:text-white"
|
||||
/>
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)] lg:items-center">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-cyan-100 shadow-lg shadow-cyan-950/30 backdrop-blur">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>Commercial use-case hub</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
|
||||
QR code use cases that fit real business workflows
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg leading-8 text-blue-50/88 md:text-xl">
|
||||
This hub focuses on workflows where dynamic updates and
|
||||
measurement matter. It is not a list of random QR ideas. It
|
||||
is the commercial layer between QR Master's product pages,
|
||||
tools, and editorial content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm text-blue-50/80 sm:grid-cols-2">
|
||||
{[
|
||||
"Use-case pages map back to a clear commercial parent.",
|
||||
"Each workflow is written for practical deployment, not filler traffic.",
|
||||
"Support resources reinforce the wedge around dynamic and trackable QR flows.",
|
||||
"The next cluster expansion will build on measurable routing and internal links.",
|
||||
].map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 backdrop-blur-sm"
|
||||
>
|
||||
<Route className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={featuredUseCases[0].href}
|
||||
ctaLabel="Explore restaurant menu QR codes"
|
||||
ctaLocation="hero_primary"
|
||||
pageType="use_case_hub"
|
||||
cluster="all-use-cases"
|
||||
>
|
||||
<Button size="lg" className="w-full bg-white px-8 py-4 text-slate-950 hover:bg-slate-100 sm:w-auto">
|
||||
Explore featured workflows
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href="/qr-code-for-marketing-campaigns"
|
||||
ctaLabel="View marketing campaign QR page"
|
||||
ctaLocation="hero_secondary"
|
||||
pageType="use_case_hub"
|
||||
cluster="all-use-cases"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 px-8 py-4 text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
See campaign workflows
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-white/10 bg-white/10 p-8 text-white shadow-2xl shadow-slate-950/30 backdrop-blur">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Compass className="h-5 w-5 text-cyan-300" />
|
||||
<h2 className="text-2xl font-bold">How to use this hub</h2>
|
||||
</div>
|
||||
<div className="space-y-4 text-sm leading-6 text-blue-50/82">
|
||||
<p>
|
||||
Start with the workflow problem, not the QR format. If the
|
||||
printed code needs to survive destination changes or you
|
||||
need proof of performance, begin with the use case that
|
||||
matches that job.
|
||||
</p>
|
||||
<p>
|
||||
Each page below links back to the best product parent,
|
||||
forward to related workflows, and sideways to educational
|
||||
resources that help you deploy the QR well.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
All live use cases
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold text-slate-900">
|
||||
Every currently published workflow route
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-slate-600">
|
||||
These are all currently published use-case routes in the growth
|
||||
layer. Each one maps back to a clear commercial parent and a
|
||||
measurable print or post-scan workflow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{allUseCases.map((page) => (
|
||||
<Link key={page.slug} href={page.href} className="group block">
|
||||
<Card className="flex h-full flex-col rounded-3xl border-slate-200 bg-white p-7 shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.18em] text-blue-700">
|
||||
{page.cluster}
|
||||
</div>
|
||||
<h3 className="mt-4 text-2xl font-bold text-slate-900">
|
||||
{page.title}
|
||||
</h3>
|
||||
<p className="mt-4 flex-1 text-base leading-7 text-slate-600">
|
||||
{page.summary}
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-between rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<span>Primary parent: {page.parentTitle}</span>
|
||||
<ArrowRight className="h-4 w-4 text-blue-700 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-50 py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)]">
|
||||
<Card className="rounded-3xl border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<LibraryBig className="h-5 w-5 text-blue-700" />
|
||||
<h2 className="text-2xl font-bold text-slate-900">
|
||||
Commercial pages that anchor the hub
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{commercialPages.map((page) => (
|
||||
<Link
|
||||
key={page.href}
|
||||
href={page.href}
|
||||
className="rounded-2xl border border-slate-200 p-4 transition-colors hover:border-blue-200 hover:bg-blue-50/60"
|
||||
>
|
||||
<div className={`h-1.5 rounded-full bg-gradient-to-r ${page.accent}`} />
|
||||
<div className="mt-4 text-lg font-semibold text-slate-900">
|
||||
{page.title}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{page.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-3xl border-slate-200 bg-slate-950 p-8 text-white shadow-xl shadow-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link2 className="h-5 w-5 text-cyan-300" />
|
||||
<h2 className="text-2xl font-bold">Support resources</h2>
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
{supportResources.map((resource) => (
|
||||
<Link
|
||||
key={resource.href}
|
||||
href={resource.href}
|
||||
className="block rounded-2xl border border-white/10 bg-white/5 p-4 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{resource.title}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-blue-50/78">
|
||||
{resource.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
Next cluster candidates
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold text-slate-900">
|
||||
What follows after the first use-case wave
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-slate-600">
|
||||
These are not published use-case routes yet. They are the next
|
||||
practical cluster expansions once the first hub and CTA layer are
|
||||
established.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{upcomingUseCaseIdeas.map((item) => (
|
||||
<Card
|
||||
key={item.title}
|
||||
className="rounded-3xl border-dashed border-slate-300 bg-slate-50 p-7"
|
||||
>
|
||||
<div className="text-xl font-semibold text-slate-900">
|
||||
{item.title}
|
||||
</div>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="mt-5 text-sm font-semibold text-blue-700">
|
||||
Anchored by {item.href.replace("/", "")}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
Compass,
|
||||
LibraryBig,
|
||||
Link2,
|
||||
Route,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs, { BreadcrumbItem } from "@/components/Breadcrumbs";
|
||||
import SeoJsonLd from "@/components/SeoJsonLd";
|
||||
import {
|
||||
MarketingPageTracker,
|
||||
TrackedCtaLink,
|
||||
} from "@/components/marketing/MarketingAnalytics";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import {
|
||||
allUseCases,
|
||||
commercialPages,
|
||||
featuredUseCases,
|
||||
supportResources,
|
||||
upcomingUseCaseIdeas,
|
||||
} from "@/lib/growth-pages";
|
||||
import { breadcrumbSchema } from "@/lib/schema";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: "QR Code Use Cases for Business | QR Master",
|
||||
},
|
||||
description:
|
||||
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
|
||||
alternates: {
|
||||
canonical: "https://www.qrmaster.net/use-cases",
|
||||
languages: {
|
||||
"x-default": "https://www.qrmaster.net/use-cases",
|
||||
en: "https://www.qrmaster.net/use-cases",
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: "QR Code Use Cases for Business | QR Master",
|
||||
description:
|
||||
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
|
||||
url: "https://www.qrmaster.net/use-cases",
|
||||
type: "website",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
twitter: {
|
||||
title: "QR Code Use Cases for Business | QR Master",
|
||||
description:
|
||||
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function UseCasesHubPage() {
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{ name: "Home", url: "/" },
|
||||
{ name: "Use Cases", url: "/use-cases" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[breadcrumbSchema(breadcrumbItems)]} />
|
||||
<MarketingPageTracker pageType="use_case_hub" cluster="all-use-cases" />
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-950 text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_34%),radial-gradient(circle_at_right,rgba(255,255,255,0.06),transparent_28%)]" />
|
||||
<div className="relative container mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
|
||||
<Breadcrumbs
|
||||
items={breadcrumbItems}
|
||||
className="[&_a]:text-blue-100/80 [&_a:hover]:text-white [&_span]:text-blue-100/80 [&_[aria-current=page]]:text-white"
|
||||
/>
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)] lg:items-center">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-cyan-100 shadow-lg shadow-cyan-950/30 backdrop-blur">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>Commercial use-case hub</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
|
||||
QR code use cases that fit real business workflows
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg leading-8 text-blue-50/88 md:text-xl">
|
||||
This hub focuses on workflows where dynamic updates and
|
||||
measurement matter. It is not a list of random QR ideas. It
|
||||
is the commercial layer between QR Master's product pages,
|
||||
tools, and editorial content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm text-blue-50/80 sm:grid-cols-2">
|
||||
{[
|
||||
"Use-case pages map back to a clear commercial parent.",
|
||||
"Each workflow is written for practical deployment, not filler traffic.",
|
||||
"Support resources reinforce the wedge around dynamic and trackable QR flows.",
|
||||
"The next cluster expansion will build on measurable routing and internal links.",
|
||||
].map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 backdrop-blur-sm"
|
||||
>
|
||||
<Route className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={featuredUseCases[0].href}
|
||||
ctaLabel="Explore restaurant menu QR codes"
|
||||
ctaLocation="hero_primary"
|
||||
pageType="use_case_hub"
|
||||
cluster="all-use-cases"
|
||||
>
|
||||
<Button size="lg" className="w-full bg-white px-8 py-4 text-slate-950 hover:bg-slate-100 sm:w-auto">
|
||||
Explore featured workflows
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href="/qr-code-for-marketing-campaigns"
|
||||
ctaLabel="View marketing campaign QR page"
|
||||
ctaLocation="hero_secondary"
|
||||
pageType="use_case_hub"
|
||||
cluster="all-use-cases"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 px-8 py-4 text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
See campaign workflows
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-white/10 bg-white/10 p-8 text-white shadow-2xl shadow-slate-950/30 backdrop-blur">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Compass className="h-5 w-5 text-cyan-300" />
|
||||
<h2 className="text-2xl font-bold">How to use this hub</h2>
|
||||
</div>
|
||||
<div className="space-y-4 text-sm leading-6 text-blue-50/82">
|
||||
<p>
|
||||
Start with the workflow problem, not the QR format. If the
|
||||
printed code needs to survive destination changes or you
|
||||
need proof of performance, begin with the use case that
|
||||
matches that job.
|
||||
</p>
|
||||
<p>
|
||||
Each page below links back to the best product parent,
|
||||
forward to related workflows, and sideways to educational
|
||||
resources that help you deploy the QR well.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
All live use cases
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold text-slate-900">
|
||||
Every currently published workflow route
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-slate-600">
|
||||
These are all currently published use-case routes in the growth
|
||||
layer. Each one maps back to a clear commercial parent and a
|
||||
measurable print or post-scan workflow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{allUseCases.map((page) => (
|
||||
<Link key={page.slug} href={page.href} className="group block">
|
||||
<Card className="flex h-full flex-col rounded-3xl border-slate-200 bg-white p-7 shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.18em] text-blue-700">
|
||||
{page.cluster}
|
||||
</div>
|
||||
<h3 className="mt-4 text-2xl font-bold text-slate-900">
|
||||
{page.title}
|
||||
</h3>
|
||||
<p className="mt-4 flex-1 text-base leading-7 text-slate-600">
|
||||
{page.summary}
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-between rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<span>Primary parent: {page.parentTitle}</span>
|
||||
<ArrowRight className="h-4 w-4 text-blue-700 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-50 py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)]">
|
||||
<Card className="rounded-3xl border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<LibraryBig className="h-5 w-5 text-blue-700" />
|
||||
<h2 className="text-2xl font-bold text-slate-900">
|
||||
Commercial pages that anchor the hub
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{commercialPages.map((page) => (
|
||||
<Link
|
||||
key={page.href}
|
||||
href={page.href}
|
||||
className="rounded-2xl border border-slate-200 p-4 transition-colors hover:border-blue-200 hover:bg-blue-50/60"
|
||||
>
|
||||
<div className={`h-1.5 rounded-full bg-gradient-to-r ${page.accent}`} />
|
||||
<div className="mt-4 text-lg font-semibold text-slate-900">
|
||||
{page.title}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{page.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-3xl border-slate-200 bg-slate-950 p-8 text-white shadow-xl shadow-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link2 className="h-5 w-5 text-cyan-300" />
|
||||
<h2 className="text-2xl font-bold">Support resources</h2>
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
{supportResources.map((resource) => (
|
||||
<Link
|
||||
key={resource.href}
|
||||
href={resource.href}
|
||||
className="block rounded-2xl border border-white/10 bg-white/5 p-4 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{resource.title}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-blue-50/78">
|
||||
{resource.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
Next cluster candidates
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold text-slate-900">
|
||||
What follows after the first use-case wave
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-slate-600">
|
||||
These are not published use-case routes yet. They are the next
|
||||
practical cluster expansions once the first hub and CTA layer are
|
||||
established.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{upcomingUseCaseIdeas.map((item) => (
|
||||
<Card
|
||||
key={item.title}
|
||||
className="rounded-3xl border-dashed border-slate-300 bg-slate-50 p-7"
|
||||
>
|
||||
<div className="text-xl font-semibold text-slate-900">
|
||||
{item.title}
|
||||
</div>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="mt-5 text-sm font-semibold text-blue-700">
|
||||
Anchored by {item.href.replace("/", "")}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
import NextAuth from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,89 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { sendPasswordResetEmail } from '@/lib/email';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Verify CSRF token
|
||||
const csrfCheck = csrfProtection(req);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error || 'Invalid CSRF token' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
// For security, always return success even if email doesn't exist
|
||||
// This prevents email enumeration attacks
|
||||
if (!user) {
|
||||
console.log('Password reset requested for non-existent email:', email);
|
||||
return NextResponse.json(
|
||||
{ message: 'If an account with that email exists, a password reset link has been sent.' },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate secure random token
|
||||
const resetToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Set token expiration to 1 hour from now
|
||||
const resetExpires = new Date(Date.now() + 3600000); // 1 hour
|
||||
|
||||
// Save token and expiration to database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetPasswordToken: resetToken,
|
||||
resetPasswordExpires: resetExpires,
|
||||
},
|
||||
});
|
||||
|
||||
// Send password reset email
|
||||
try {
|
||||
await sendPasswordResetEmail(email, resetToken);
|
||||
} catch (emailError) {
|
||||
console.error('Error sending password reset email:', emailError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send reset email. Please try again later.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Password reset email sent successfully' },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in forgot-password route:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { sendPasswordResetEmail } from '@/lib/email';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Verify CSRF token
|
||||
const csrfCheck = csrfProtection(req);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error || 'Invalid CSRF token' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
// For security, always return success even if email doesn't exist
|
||||
// This prevents email enumeration attacks
|
||||
if (!user) {
|
||||
console.log('Password reset requested for non-existent email:', email);
|
||||
return NextResponse.json(
|
||||
{ message: 'If an account with that email exists, a password reset link has been sent.' },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate secure random token
|
||||
const resetToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Set token expiration to 1 hour from now
|
||||
const resetExpires = new Date(Date.now() + 3600000); // 1 hour
|
||||
|
||||
// Save token and expiration to database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetPasswordToken: resetToken,
|
||||
resetPasswordExpires: resetExpires,
|
||||
},
|
||||
});
|
||||
|
||||
// Send password reset email
|
||||
try {
|
||||
await sendPasswordResetEmail(email, resetToken);
|
||||
} catch (emailError) {
|
||||
console.error('Error sending password reset email:', emailError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send reset email. Please try again later.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Password reset email sent successfully' },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in forgot-password route:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +1,165 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
|
||||
// If no code, redirect to Google OAuth
|
||||
if (!code) {
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
|
||||
if (!googleClientId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Google Client ID not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||
const scope = 'openid email profile';
|
||||
|
||||
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`;
|
||||
|
||||
return NextResponse.redirect(googleAuthUrl);
|
||||
}
|
||||
|
||||
// Handle callback with code
|
||||
try {
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
if (!googleClientId || !googleClientSecret) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Google OAuth not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: googleClientId,
|
||||
client_secret: googleClientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error('Failed to exchange code for tokens');
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// Get user info from Google
|
||||
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userInfoResponse.ok) {
|
||||
throw new Error('Failed to get user info');
|
||||
}
|
||||
|
||||
const userInfo = await userInfoResponse.json();
|
||||
|
||||
// Check if user exists in database
|
||||
let user = await db.user.findUnique({
|
||||
where: { email: userInfo.email },
|
||||
});
|
||||
|
||||
const isNewUser = !user;
|
||||
|
||||
// Create user if they don't exist
|
||||
if (!user) {
|
||||
user = await db.user.create({
|
||||
data: {
|
||||
email: userInfo.email,
|
||||
name: userInfo.name || userInfo.email.split('@')[0],
|
||||
image: userInfo.picture,
|
||||
emailVerified: new Date(), // Google already verified the email
|
||||
password: null, // OAuth users don't need a password
|
||||
},
|
||||
});
|
||||
|
||||
// Create Account entry for the OAuth provider
|
||||
await db.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'oauth',
|
||||
provider: 'google',
|
||||
providerAccountId: userInfo.sub || userInfo.id,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||
token_type: tokens.token_type,
|
||||
scope: tokens.scope,
|
||||
id_token: tokens.id_token,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Update existing account tokens
|
||||
const existingAccount = await db.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: 'google',
|
||||
providerAccountId: userInfo.sub || userInfo.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAccount) {
|
||||
await db.account.update({
|
||||
where: { id: existingAccount.id },
|
||||
data: {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create Account entry if it doesn't exist
|
||||
await db.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'oauth',
|
||||
provider: 'google',
|
||||
providerAccountId: userInfo.sub || userInfo.id,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||
token_type: tokens.token_type,
|
||||
scope: tokens.scope,
|
||||
id_token: tokens.id_token,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set authentication cookie
|
||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||
|
||||
// Redirect to dashboard with tracking params
|
||||
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
|
||||
redirectUrl.searchParams.set('authMethod', 'google');
|
||||
redirectUrl.searchParams.set('isNewUser', isNewUser.toString());
|
||||
|
||||
return NextResponse.redirect(redirectUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('Google OAuth error:', error);
|
||||
return NextResponse.redirect(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
|
||||
// If no code, redirect to Google OAuth
|
||||
if (!code) {
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
|
||||
if (!googleClientId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Google Client ID not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||
const scope = 'openid email profile';
|
||||
|
||||
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`;
|
||||
|
||||
return NextResponse.redirect(googleAuthUrl);
|
||||
}
|
||||
|
||||
// Handle callback with code
|
||||
try {
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
if (!googleClientId || !googleClientSecret) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Google OAuth not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: googleClientId,
|
||||
client_secret: googleClientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error('Failed to exchange code for tokens');
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// Get user info from Google
|
||||
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userInfoResponse.ok) {
|
||||
throw new Error('Failed to get user info');
|
||||
}
|
||||
|
||||
const userInfo = await userInfoResponse.json();
|
||||
|
||||
// Check if user exists in database
|
||||
let user = await db.user.findUnique({
|
||||
where: { email: userInfo.email },
|
||||
});
|
||||
|
||||
const isNewUser = !user;
|
||||
|
||||
// Create user if they don't exist
|
||||
if (!user) {
|
||||
user = await db.user.create({
|
||||
data: {
|
||||
email: userInfo.email,
|
||||
name: userInfo.name || userInfo.email.split('@')[0],
|
||||
image: userInfo.picture,
|
||||
emailVerified: new Date(), // Google already verified the email
|
||||
password: null, // OAuth users don't need a password
|
||||
},
|
||||
});
|
||||
|
||||
// Create Account entry for the OAuth provider
|
||||
await db.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'oauth',
|
||||
provider: 'google',
|
||||
providerAccountId: userInfo.sub || userInfo.id,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||
token_type: tokens.token_type,
|
||||
scope: tokens.scope,
|
||||
id_token: tokens.id_token,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Update existing account tokens
|
||||
const existingAccount = await db.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: 'google',
|
||||
providerAccountId: userInfo.sub || userInfo.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAccount) {
|
||||
await db.account.update({
|
||||
where: { id: existingAccount.id },
|
||||
data: {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create Account entry if it doesn't exist
|
||||
await db.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'oauth',
|
||||
provider: 'google',
|
||||
providerAccountId: userInfo.sub || userInfo.id,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||
token_type: tokens.token_type,
|
||||
scope: tokens.scope,
|
||||
id_token: tokens.id_token,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set authentication cookie
|
||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||
|
||||
// Redirect to dashboard with tracking params
|
||||
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
|
||||
redirectUrl.searchParams.set('authMethod', 'google');
|
||||
redirectUrl.searchParams.set('isNewUser', isNewUser.toString());
|
||||
|
||||
return NextResponse.redirect(redirectUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('Google OAuth error:', error);
|
||||
return NextResponse.redirect(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Verify CSRF token
|
||||
const csrfCheck = csrfProtection(req);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error || 'Invalid CSRF token' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { token, password } = body;
|
||||
|
||||
if (!token || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 8 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find user with this reset token
|
||||
const user = await db.user.findUnique({
|
||||
where: { resetPasswordToken: token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired reset token' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if (!user.resetPasswordExpires || user.resetPasswordExpires < new Date()) {
|
||||
// Clear expired token
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetPasswordToken: null,
|
||||
resetPasswordExpires: null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Reset token has expired. Please request a new password reset link.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Update user's password and clear reset token
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
resetPasswordToken: null,
|
||||
resetPasswordExpires: null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Password successfully reset for user:', user.email);
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Password reset successfully' },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in reset-password route:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Verify CSRF token
|
||||
const csrfCheck = csrfProtection(req);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error || 'Invalid CSRF token' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { token, password } = body;
|
||||
|
||||
if (!token || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 8 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find user with this reset token
|
||||
const user = await db.user.findUnique({
|
||||
where: { resetPasswordToken: token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired reset token' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if (!user.resetPasswordExpires || user.resetPasswordExpires < new Date()) {
|
||||
// Clear expired token
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetPasswordToken: null,
|
||||
resetPasswordExpires: null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Reset token has expired. Please request a new password reset link.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Update user's password and clear reset token
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
resetPasswordToken: null,
|
||||
resetPasswordExpires: null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Password successfully reset for user:', user.email);
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Password reset successfully' },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in reset-password route:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Rate Limiting
|
||||
const clientId = getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.LOGIN);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many login attempts. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequest(loginSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const { email, password } = validation.data;
|
||||
|
||||
// Find user
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await bcrypt.compare(password, user.password || '');
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Set cookie
|
||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Rate Limiting
|
||||
const clientId = getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.LOGIN);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many login attempts. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequest(loginSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const { email, password } = validation.data;
|
||||
|
||||
// Find user
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await bcrypt.compare(password, user.password || '');
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Set cookie
|
||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getOrCreateCsrfToken } from '@/lib/csrf';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/csrf
|
||||
* Returns a CSRF token for the current session
|
||||
*/
|
||||
export async function GET() {
|
||||
const token = getOrCreateCsrfToken();
|
||||
|
||||
return NextResponse.json({ csrfToken: token });
|
||||
}
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getOrCreateCsrfToken } from '@/lib/csrf';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/csrf
|
||||
* Returns a CSRF token for the current session
|
||||
*/
|
||||
export async function GET() {
|
||||
const token = getOrCreateCsrfToken();
|
||||
|
||||
return NextResponse.json({ csrfToken: token });
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '@/lib/db';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/admin-login
|
||||
* Simple admin login for newsletter management (no CSRF required)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password } = body;
|
||||
|
||||
// Validate input
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: Only allow support@qrmaster.net to access newsletter admin
|
||||
const ALLOWED_ADMIN_EMAIL = 'support@qrmaster.net';
|
||||
const ALLOWED_ADMIN_PASSWORD = 'Timo.16092005';
|
||||
|
||||
if (email.toLowerCase() !== ALLOWED_ADMIN_EMAIL) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied. Only authorized accounts can access this area.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password with hardcoded value
|
||||
if (password !== ALLOWED_ADMIN_PASSWORD) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Set auth cookie with a simple session identifier
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
});
|
||||
|
||||
response.cookies.set('newsletter-admin', 'authenticated', getAuthCookieOptions());
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Newsletter admin login error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Login failed. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '@/lib/db';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/admin-login
|
||||
* Simple admin login for newsletter management (no CSRF required)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password } = body;
|
||||
|
||||
// Validate input
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: Only allow support@qrmaster.net to access newsletter admin
|
||||
const ALLOWED_ADMIN_EMAIL = 'support@qrmaster.net';
|
||||
const ALLOWED_ADMIN_PASSWORD = 'Timo.16092005';
|
||||
|
||||
if (email.toLowerCase() !== ALLOWED_ADMIN_EMAIL) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied. Only authorized accounts can access this area.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password with hardcoded value
|
||||
if (password !== ALLOWED_ADMIN_PASSWORD) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Set auth cookie with a simple session identifier
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
});
|
||||
|
||||
response.cookies.set('newsletter-admin', 'authenticated', getAuthCookieOptions());
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Newsletter admin login error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Login failed. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +1,163 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { sendAIFeatureLaunchEmail } from '@/lib/email';
|
||||
import { rateLimit, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/broadcast
|
||||
* Send AI feature launch email to all subscribed users
|
||||
* PROTECTED: Only authenticated users can access (you may want to add admin check)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication using newsletter-admin cookie
|
||||
const adminCookie = cookies().get('newsletter-admin')?.value;
|
||||
|
||||
if (adminCookie !== 'authenticated') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Please log in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Optional: Add admin check here
|
||||
// const user = await db.user.findUnique({ where: { id: userId } });
|
||||
// if (user?.role !== 'ADMIN') {
|
||||
// return NextResponse.json({ error: 'Forbidden. Admin access required.' }, { status: 403 });
|
||||
// }
|
||||
|
||||
// Rate limiting (prevent accidental spam)
|
||||
const rateLimitResult = rateLimit('newsletter-admin', {
|
||||
name: 'newsletter-broadcast',
|
||||
maxRequests: 2, // Only 2 broadcasts per hour
|
||||
windowSeconds: 60 * 60,
|
||||
});
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many broadcast attempts. Please wait before trying again.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get all subscribed users
|
||||
const subscribers = await db.newsletterSubscription.findMany({
|
||||
where: {
|
||||
status: 'subscribed',
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No subscribers found',
|
||||
sent: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Send emails in batches to avoid overwhelming Resend
|
||||
const batchSize = 10;
|
||||
const results = {
|
||||
sent: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
for (let i = 0; i < subscribers.length; i += batchSize) {
|
||||
const batch = subscribers.slice(i, i + batchSize);
|
||||
|
||||
// Send emails in parallel within batch
|
||||
const promises = batch.map(async (subscriber) => {
|
||||
try {
|
||||
await sendAIFeatureLaunchEmail(subscriber.email);
|
||||
results.sent++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(`Failed to send to ${subscriber.email}`);
|
||||
console.error(`Failed to send to ${subscriber.email}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
// Small delay between batches to be nice to the email service
|
||||
if (i + batchSize < subscribers.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Broadcast completed. Sent to ${results.sent} subscribers.`,
|
||||
sent: results.sent,
|
||||
failed: results.failed,
|
||||
total: subscribers.length,
|
||||
errors: results.errors.length > 0 ? results.errors : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Newsletter broadcast error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to send broadcast emails. Please try again.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/newsletter/broadcast
|
||||
* Get subscriber count and preview
|
||||
* PROTECTED: Only authenticated users
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication using newsletter-admin cookie
|
||||
const adminCookie = cookies().get('newsletter-admin')?.value;
|
||||
|
||||
if (adminCookie !== 'authenticated') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Please log in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const subscriberCount = await db.newsletterSubscription.count({
|
||||
where: {
|
||||
status: 'subscribed',
|
||||
},
|
||||
});
|
||||
|
||||
const recentSubscribers = await db.newsletterSubscription.findMany({
|
||||
where: {
|
||||
status: 'subscribed',
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
total: subscriberCount,
|
||||
recent: recentSubscribers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriber info:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch subscriber information' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { sendAIFeatureLaunchEmail } from '@/lib/email';
|
||||
import { rateLimit, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/broadcast
|
||||
* Send AI feature launch email to all subscribed users
|
||||
* PROTECTED: Only authenticated users can access (you may want to add admin check)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication using newsletter-admin cookie
|
||||
const adminCookie = cookies().get('newsletter-admin')?.value;
|
||||
|
||||
if (adminCookie !== 'authenticated') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Please log in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Optional: Add admin check here
|
||||
// const user = await db.user.findUnique({ where: { id: userId } });
|
||||
// if (user?.role !== 'ADMIN') {
|
||||
// return NextResponse.json({ error: 'Forbidden. Admin access required.' }, { status: 403 });
|
||||
// }
|
||||
|
||||
// Rate limiting (prevent accidental spam)
|
||||
const rateLimitResult = rateLimit('newsletter-admin', {
|
||||
name: 'newsletter-broadcast',
|
||||
maxRequests: 2, // Only 2 broadcasts per hour
|
||||
windowSeconds: 60 * 60,
|
||||
});
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many broadcast attempts. Please wait before trying again.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get all subscribed users
|
||||
const subscribers = await db.newsletterSubscription.findMany({
|
||||
where: {
|
||||
status: 'subscribed',
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No subscribers found',
|
||||
sent: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Send emails in batches to avoid overwhelming Resend
|
||||
const batchSize = 10;
|
||||
const results = {
|
||||
sent: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
for (let i = 0; i < subscribers.length; i += batchSize) {
|
||||
const batch = subscribers.slice(i, i + batchSize);
|
||||
|
||||
// Send emails in parallel within batch
|
||||
const promises = batch.map(async (subscriber) => {
|
||||
try {
|
||||
await sendAIFeatureLaunchEmail(subscriber.email);
|
||||
results.sent++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(`Failed to send to ${subscriber.email}`);
|
||||
console.error(`Failed to send to ${subscriber.email}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
// Small delay between batches to be nice to the email service
|
||||
if (i + batchSize < subscribers.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Broadcast completed. Sent to ${results.sent} subscribers.`,
|
||||
sent: results.sent,
|
||||
failed: results.failed,
|
||||
total: subscribers.length,
|
||||
errors: results.errors.length > 0 ? results.errors : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Newsletter broadcast error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to send broadcast emails. Please try again.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/newsletter/broadcast
|
||||
* Get subscriber count and preview
|
||||
* PROTECTED: Only authenticated users
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication using newsletter-admin cookie
|
||||
const adminCookie = cookies().get('newsletter-admin')?.value;
|
||||
|
||||
if (adminCookie !== 'authenticated') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Please log in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const subscriberCount = await db.newsletterSubscription.count({
|
||||
where: {
|
||||
status: 'subscribed',
|
||||
},
|
||||
});
|
||||
|
||||
const recentSubscribers = await db.newsletterSubscription.findMany({
|
||||
where: {
|
||||
status: 'subscribed',
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
total: subscriberCount,
|
||||
recent: recentSubscribers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriber info:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch subscriber information' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +1,91 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { newsletterSubscribeSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { sendNewsletterWelcomeEmail } from '@/lib/email';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/subscribe
|
||||
* Subscribe to AI features newsletter
|
||||
* Public endpoint - no authentication required
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get client identifier for rate limiting
|
||||
const clientId = getClientIdentifier(request);
|
||||
|
||||
// Apply rate limiting (5 per hour)
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.NEWSLETTER_SUBSCRIBE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many subscription attempts. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
'Retry-After': Math.ceil((rateLimitResult.reset - Date.now()) / 1000).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json();
|
||||
const validation = await validateRequest(newsletterSubscribeSchema, body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
// Check if email already subscribed
|
||||
const existing = await db.newsletterSubscription.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// If already subscribed, return success (idempotent)
|
||||
// Don't reveal if email exists for privacy
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully subscribed to AI features newsletter!',
|
||||
alreadySubscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Create new subscription
|
||||
await db.newsletterSubscription.create({
|
||||
data: {
|
||||
email,
|
||||
source: 'ai-coming-soon',
|
||||
status: 'subscribed',
|
||||
},
|
||||
});
|
||||
|
||||
// Send welcome email (don't block response)
|
||||
sendNewsletterWelcomeEmail(email).catch((error) => {
|
||||
console.error('Failed to send welcome email (non-blocking):', error);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully subscribed to AI features newsletter!',
|
||||
alreadySubscribed: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Newsletter subscription error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to subscribe to newsletter. Please try again.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { newsletterSubscribeSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { sendNewsletterWelcomeEmail } from '@/lib/email';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/subscribe
|
||||
* Subscribe to AI features newsletter
|
||||
* Public endpoint - no authentication required
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get client identifier for rate limiting
|
||||
const clientId = getClientIdentifier(request);
|
||||
|
||||
// Apply rate limiting (5 per hour)
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.NEWSLETTER_SUBSCRIBE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many subscription attempts. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
'Retry-After': Math.ceil((rateLimitResult.reset - Date.now()) / 1000).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json();
|
||||
const validation = await validateRequest(newsletterSubscribeSchema, body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
// Check if email already subscribed
|
||||
const existing = await db.newsletterSubscription.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// If already subscribed, return success (idempotent)
|
||||
// Don't reveal if email exists for privacy
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully subscribed to AI features newsletter!',
|
||||
alreadySubscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Create new subscription
|
||||
await db.newsletterSubscription.create({
|
||||
data: {
|
||||
email,
|
||||
source: 'ai-coming-soon',
|
||||
status: 'subscribed',
|
||||
},
|
||||
});
|
||||
|
||||
// Send welcome email (don't block response)
|
||||
sendNewsletterWelcomeEmail(email).catch((error) => {
|
||||
console.error('Failed to send welcome email (non-blocking):', error);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully subscribed to AI features newsletter!',
|
||||
alreadySubscribed: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Newsletter subscription error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to subscribe to newsletter. Please try again.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,205 +1,205 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { z } from 'zod';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
const updateQRSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
content: z.any().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
style: z.any().optional(),
|
||||
});
|
||||
|
||||
// GET /api/qrs/[id] - Get a single QR code
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const qrCode = await db.qRCode.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
scans: {
|
||||
orderBy: { ts: 'desc' },
|
||||
take: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(qrCode);
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/qrs/[id] - Update a QR code
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = updateQRSchema.parse(body);
|
||||
|
||||
// Check ownership
|
||||
const existing = await db.qRCode.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Static QR codes cannot be edited
|
||||
if (existing.type === 'STATIC' && data.content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Static QR codes cannot be edited' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update QR code
|
||||
const updated = await db.qRCode.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...(data.title && { title: data.title }),
|
||||
...(data.content && { content: data.content }),
|
||||
...(data.tags && { tags: data.tags }),
|
||||
...(data.style && { style: data.style }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Error updating QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/qrs/[id] - Delete a QR code
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check ownership
|
||||
const existing = await db.qRCode.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete QR code (cascades to scans)
|
||||
await db.qRCode.delete({
|
||||
where: { id: params.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { z } from 'zod';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
const updateQRSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
content: z.any().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
style: z.any().optional(),
|
||||
});
|
||||
|
||||
// GET /api/qrs/[id] - Get a single QR code
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const qrCode = await db.qRCode.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
scans: {
|
||||
orderBy: { ts: 'desc' },
|
||||
take: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(qrCode);
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/qrs/[id] - Update a QR code
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = updateQRSchema.parse(body);
|
||||
|
||||
// Check ownership
|
||||
const existing = await db.qRCode.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Static QR codes cannot be edited
|
||||
if (existing.type === 'STATIC' && data.content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Static QR codes cannot be edited' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update QR code
|
||||
const updated = await db.qRCode.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...(data.title && { title: data.title }),
|
||||
...(data.content && { content: data.content }),
|
||||
...(data.tags && { tags: data.tags }),
|
||||
...(data.style && { style: data.style }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Error updating QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/qrs/[id] - Delete a QR code
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check ownership
|
||||
const existing = await db.qRCode.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete QR code (cascades to scans)
|
||||
await db.qRCode.delete({
|
||||
where: { id: params.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_DELETE_ALL);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Delete all QR codes for this user
|
||||
const result = await db.qRCode.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedCount: result.count,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting all QR codes:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_DELETE_ALL);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Delete all QR codes for this user
|
||||
const result = await db.qRCode.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedCount: result.count,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting all QR codes:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { generateSlug } from '@/lib/hash';
|
||||
|
||||
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, contentType, content, tags, style } = body;
|
||||
|
||||
// Generate the actual QR content based on type
|
||||
let qrContent = '';
|
||||
switch (contentType) {
|
||||
case 'URL':
|
||||
qrContent = content.url;
|
||||
break;
|
||||
case 'PHONE':
|
||||
qrContent = `tel:${content.phone}`;
|
||||
break;
|
||||
case 'SMS':
|
||||
qrContent = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
qrContent = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${content.firstName || ''} ${content.lastName || ''}
|
||||
N:${content.lastName || ''};${content.firstName || ''};;;
|
||||
${content.organization ? `ORG:${content.organization}` : ''}
|
||||
${content.title ? `TITLE:${content.title}` : ''}
|
||||
${content.email ? `EMAIL:${content.email}` : ''}
|
||||
${content.phone ? `TEL:${content.phone}` : ''}
|
||||
END:VCARD`;
|
||||
break;
|
||||
case 'GEO':
|
||||
const lat = content.latitude || 0;
|
||||
const lon = content.longitude || 0;
|
||||
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
|
||||
qrContent = `geo:${lat},${lon}${label}`;
|
||||
break;
|
||||
case 'TEXT':
|
||||
qrContent = content.text;
|
||||
break;
|
||||
case 'WHATSAPP':
|
||||
qrContent = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
||||
break;
|
||||
default:
|
||||
qrContent = content.url || 'https://example.com';
|
||||
}
|
||||
|
||||
// Store the QR content in a special field
|
||||
const enrichedContent = {
|
||||
...content,
|
||||
qrContent // This is what the QR code should actually contain
|
||||
};
|
||||
|
||||
// Generate slug
|
||||
const slug = generateSlug(title);
|
||||
|
||||
// Create QR code
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
type: 'STATIC',
|
||||
contentType,
|
||||
content: enrichedContent,
|
||||
tags: tags || [],
|
||||
style: style || {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cornerStyle: 'square',
|
||||
size: 200,
|
||||
},
|
||||
slug,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(qrCode);
|
||||
} catch (error) {
|
||||
console.error('Error creating static QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { generateSlug } from '@/lib/hash';
|
||||
|
||||
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, contentType, content, tags, style } = body;
|
||||
|
||||
// Generate the actual QR content based on type
|
||||
let qrContent = '';
|
||||
switch (contentType) {
|
||||
case 'URL':
|
||||
qrContent = content.url;
|
||||
break;
|
||||
case 'PHONE':
|
||||
qrContent = `tel:${content.phone}`;
|
||||
break;
|
||||
case 'SMS':
|
||||
qrContent = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
qrContent = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${content.firstName || ''} ${content.lastName || ''}
|
||||
N:${content.lastName || ''};${content.firstName || ''};;;
|
||||
${content.organization ? `ORG:${content.organization}` : ''}
|
||||
${content.title ? `TITLE:${content.title}` : ''}
|
||||
${content.email ? `EMAIL:${content.email}` : ''}
|
||||
${content.phone ? `TEL:${content.phone}` : ''}
|
||||
END:VCARD`;
|
||||
break;
|
||||
case 'GEO':
|
||||
const lat = content.latitude || 0;
|
||||
const lon = content.longitude || 0;
|
||||
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
|
||||
qrContent = `geo:${lat},${lon}${label}`;
|
||||
break;
|
||||
case 'TEXT':
|
||||
qrContent = content.text;
|
||||
break;
|
||||
case 'WHATSAPP':
|
||||
qrContent = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
||||
break;
|
||||
default:
|
||||
qrContent = content.url || 'https://example.com';
|
||||
}
|
||||
|
||||
// Store the QR content in a special field
|
||||
const enrichedContent = {
|
||||
...content,
|
||||
qrContent // This is what the QR code should actually contain
|
||||
};
|
||||
|
||||
// Generate slug
|
||||
const slug = generateSlug(title);
|
||||
|
||||
// Create QR code
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
type: 'STATIC',
|
||||
contentType,
|
||||
content: enrichedContent,
|
||||
tags: tags || [],
|
||||
style: style || {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cornerStyle: 'square',
|
||||
size: 200,
|
||||
},
|
||||
slug,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(qrCode);
|
||||
} catch (error) {
|
||||
console.error('Error creating static QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CANCEL);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user with subscription info
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
stripeSubscriptionId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Already on free plan
|
||||
if (user.plan === 'FREE') {
|
||||
return NextResponse.json({ error: 'Already on free plan' }, { status: 400 });
|
||||
}
|
||||
|
||||
// No active subscription
|
||||
if (!user.stripeSubscriptionId) {
|
||||
// Just update plan to FREE if somehow plan is not FREE but no subscription
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: 'FREE',
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Cancel the Stripe subscription
|
||||
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
|
||||
|
||||
// Update user plan to FREE
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: 'FREE',
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to cancel subscription' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CANCEL);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user with subscription info
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
stripeSubscriptionId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Already on free plan
|
||||
if (user.plan === 'FREE') {
|
||||
return NextResponse.json({ error: 'Already on free plan' }, { status: 400 });
|
||||
}
|
||||
|
||||
// No active subscription
|
||||
if (!user.stripeSubscriptionId) {
|
||||
// Just update plan to FREE if somehow plan is not FREE but no subscription
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: 'FREE',
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Cancel the Stripe subscription
|
||||
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
|
||||
|
||||
// Update user plan to FREE
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: 'FREE',
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to cancel subscription' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get user email from request body (since we're using simple auth, not NextAuth)
|
||||
const { priceId, plan, userEmail } = await request.json();
|
||||
|
||||
if (!userEmail) {
|
||||
return NextResponse.json({ error: 'Unauthorized - No user email provided' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!priceId || !plan) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing priceId or plan' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: userEmail },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create or get Stripe customer
|
||||
let customerId = user.stripeCustomerId;
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
customerId = customer.id;
|
||||
|
||||
// Update user with Stripe customer ID
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { stripeCustomerId: customerId },
|
||||
});
|
||||
}
|
||||
|
||||
// Create Stripe Checkout Session
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url });
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get user email from request body (since we're using simple auth, not NextAuth)
|
||||
const { priceId, plan, userEmail } = await request.json();
|
||||
|
||||
if (!userEmail) {
|
||||
return NextResponse.json({ error: 'Unauthorized - No user email provided' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!priceId || !plan) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing priceId or plan' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: userEmail },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create or get Stripe customer
|
||||
let customerId = user.stripeCustomerId;
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
customerId = customer.id;
|
||||
|
||||
// Update user with Stripe customer ID
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { stripeCustomerId: customerId },
|
||||
});
|
||||
}
|
||||
|
||||
// Create Stripe Checkout Session
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url });
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +1,115 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { stripe, STRIPE_PLANS } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get user from cookie (using userId like other routes)
|
||||
const cookieStore = await cookies();
|
||||
const userId = cookieStore.get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CHECKOUT);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized - Please log in' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get plan and billing interval from request
|
||||
const { plan, billingInterval = 'month' } = await request.json();
|
||||
|
||||
if (!plan || !['PRO', 'BUSINESS'].includes(plan)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid plan. Must be PRO or BUSINESS' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the Stripe price ID for the plan
|
||||
const planConfig = STRIPE_PLANS[plan as 'PRO' | 'BUSINESS'];
|
||||
const priceId = billingInterval === 'year' ? planConfig.priceIdYearly : planConfig.priceId;
|
||||
|
||||
if (!priceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe price ID not configured for this plan' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create or get Stripe customer
|
||||
let customerId = user.stripeCustomerId;
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
customerId = customer.id;
|
||||
|
||||
// Update user with Stripe customer ID
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { stripeCustomerId: customerId },
|
||||
});
|
||||
}
|
||||
|
||||
// Create Stripe Checkout Session
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url });
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { stripe, STRIPE_PLANS } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get user from cookie (using userId like other routes)
|
||||
const cookieStore = await cookies();
|
||||
const userId = cookieStore.get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CHECKOUT);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized - Please log in' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get plan and billing interval from request
|
||||
const { plan, billingInterval = 'month' } = await request.json();
|
||||
|
||||
if (!plan || !['PRO', 'BUSINESS'].includes(plan)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid plan. Must be PRO or BUSINESS' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the Stripe price ID for the plan
|
||||
const planConfig = STRIPE_PLANS[plan as 'PRO' | 'BUSINESS'];
|
||||
const priceId = billingInterval === 'year' ? planConfig.priceIdYearly : planConfig.priceId;
|
||||
|
||||
if (!priceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe price ID not configured for this plan' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create or get Stripe customer
|
||||
let customerId = user.stripeCustomerId;
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
customerId = customer.id;
|
||||
|
||||
// Update user with Stripe customer ID
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { stripeCustomerId: customerId },
|
||||
});
|
||||
}
|
||||
|
||||
// Create Stripe Checkout Session
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url });
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_PORTAL);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user with Stripe customer ID
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
stripeCustomerId: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// If user doesn't have a Stripe customer ID, they can't access the portal
|
||||
if (!user.stripeCustomerId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No active subscription found' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create Stripe Customer Portal session
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/settings`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: portalSession.url });
|
||||
} catch (error) {
|
||||
console.error('Error creating portal session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create portal session' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_PORTAL);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user with Stripe customer ID
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
stripeCustomerId: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// If user doesn't have a Stripe customer ID, they can't access the portal
|
||||
if (!user.stripeCustomerId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No active subscription found' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create Stripe Customer Portal session
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/settings`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: portalSession.url });
|
||||
} catch (error) {
|
||||
console.error('Error creating portal session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create portal session' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,113 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* Manual sync endpoint to update user subscription from Stripe
|
||||
* Use this if the automatic webhook/verify failed
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Use cookie-based auth
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!user.stripeCustomerId) {
|
||||
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all subscriptions for this customer
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: user.stripeCustomerId,
|
||||
status: 'active',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (subscriptions.data.length === 0) {
|
||||
// No active subscription - set to FREE
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan: 'FREE',
|
||||
message: 'No active subscription found, set to FREE plan',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription: any = subscriptions.data[0];
|
||||
|
||||
// Determine plan from price ID
|
||||
const priceId = subscription.items.data[0]?.price?.id;
|
||||
let plan = 'PRO'; // default
|
||||
|
||||
// Check against known price IDs
|
||||
if (priceId === process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY ||
|
||||
priceId === process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY) {
|
||||
plan = 'BUSINESS';
|
||||
} else if (priceId === process.env.STRIPE_PRICE_ID_PRO_MONTHLY ||
|
||||
priceId === process.env.STRIPE_PRICE_ID_PRO_YEARLY) {
|
||||
plan = 'PRO';
|
||||
}
|
||||
|
||||
// Get current_period_end
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
|
||||
const currentPeriodEnd = periodEndTimestamp
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
console.log('Syncing subscription:', {
|
||||
subscriptionId: subscription.id,
|
||||
priceId,
|
||||
plan,
|
||||
periodEndTimestamp,
|
||||
currentPeriodEnd,
|
||||
});
|
||||
|
||||
// Update user in database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: priceId,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: plan as any,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
subscriptionId: subscription.id,
|
||||
currentPeriodEnd,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error syncing subscription:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* Manual sync endpoint to update user subscription from Stripe
|
||||
* Use this if the automatic webhook/verify failed
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Use cookie-based auth
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!user.stripeCustomerId) {
|
||||
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all subscriptions for this customer
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: user.stripeCustomerId,
|
||||
status: 'active',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (subscriptions.data.length === 0) {
|
||||
// No active subscription - set to FREE
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan: 'FREE',
|
||||
message: 'No active subscription found, set to FREE plan',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription: any = subscriptions.data[0];
|
||||
|
||||
// Determine plan from price ID
|
||||
const priceId = subscription.items.data[0]?.price?.id;
|
||||
let plan = 'PRO'; // default
|
||||
|
||||
// Check against known price IDs
|
||||
if (priceId === process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY ||
|
||||
priceId === process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY) {
|
||||
plan = 'BUSINESS';
|
||||
} else if (priceId === process.env.STRIPE_PRICE_ID_PRO_MONTHLY ||
|
||||
priceId === process.env.STRIPE_PRICE_ID_PRO_YEARLY) {
|
||||
plan = 'PRO';
|
||||
}
|
||||
|
||||
// Get current_period_end
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
|
||||
const currentPeriodEnd = periodEndTimestamp
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
console.log('Syncing subscription:', {
|
||||
subscriptionId: subscription.id,
|
||||
priceId,
|
||||
plan,
|
||||
periodEndTimestamp,
|
||||
currentPeriodEnd,
|
||||
});
|
||||
|
||||
// Update user in database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: priceId,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: plan as any,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
subscriptionId: subscription.id,
|
||||
currentPeriodEnd,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error syncing subscription:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Use cookie-based auth instead of NextAuth
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!user.stripeCustomerId) {
|
||||
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the most recent checkout session for this customer
|
||||
const checkoutSessions = await stripe.checkout.sessions.list({
|
||||
customer: user.stripeCustomerId,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (checkoutSessions.data.length === 0) {
|
||||
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const checkoutSession = checkoutSessions.data[0];
|
||||
|
||||
// Only process if payment was successful
|
||||
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
||||
const subscriptionId = typeof checkoutSession.subscription === 'string'
|
||||
? checkoutSession.subscription
|
||||
: checkoutSession.subscription.id;
|
||||
|
||||
// Retrieve the full subscription object
|
||||
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
// Determine plan from metadata or price ID
|
||||
const plan = checkoutSession.metadata?.plan || 'PRO';
|
||||
|
||||
// Debug log to see the subscription structure
|
||||
console.log('Full subscription object:', JSON.stringify(subscription, null, 2));
|
||||
|
||||
// Get current_period_end - Stripe returns it as a Unix timestamp
|
||||
// Try different possible field names
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
|
||||
const currentPeriodEnd = periodEndTimestamp
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
|
||||
|
||||
console.log('Subscription data:', {
|
||||
id: subscription.id,
|
||||
periodEndTimestamp,
|
||||
currentPeriodEnd,
|
||||
priceId: subscription.items?.data?.[0]?.price?.id,
|
||||
});
|
||||
|
||||
// Update user in database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: plan as any,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('Error verifying session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Use cookie-based auth instead of NextAuth
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!user.stripeCustomerId) {
|
||||
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the most recent checkout session for this customer
|
||||
const checkoutSessions = await stripe.checkout.sessions.list({
|
||||
customer: user.stripeCustomerId,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (checkoutSessions.data.length === 0) {
|
||||
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const checkoutSession = checkoutSessions.data[0];
|
||||
|
||||
// Only process if payment was successful
|
||||
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
||||
const subscriptionId = typeof checkoutSession.subscription === 'string'
|
||||
? checkoutSession.subscription
|
||||
: checkoutSession.subscription.id;
|
||||
|
||||
// Retrieve the full subscription object
|
||||
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
// Determine plan from metadata or price ID
|
||||
const plan = checkoutSession.metadata?.plan || 'PRO';
|
||||
|
||||
// Debug log to see the subscription structure
|
||||
console.log('Full subscription object:', JSON.stringify(subscription, null, 2));
|
||||
|
||||
// Get current_period_end - Stripe returns it as a Unix timestamp
|
||||
// Try different possible field names
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
|
||||
const currentPeriodEnd = periodEndTimestamp
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
|
||||
|
||||
console.log('Subscription data:', {
|
||||
id: subscription.id,
|
||||
periodEndTimestamp,
|
||||
currentPeriodEnd,
|
||||
priceId: subscription.items?.data?.[0]?.price?.id,
|
||||
});
|
||||
|
||||
// Update user in database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: plan as any,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('Error verifying session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,116 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { headers } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.text();
|
||||
const signature = headers().get('stripe-signature');
|
||||
|
||||
if (!signature) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No signature' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Webhook signature verification failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid signature' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (session.mode === 'subscription') {
|
||||
const subscription: any = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string
|
||||
);
|
||||
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
|
||||
const currentPeriodEnd = periodEndTimestamp
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeCustomerId: session.customer as string,
|
||||
},
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: (session.metadata?.plan || 'FREE') as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription: any = event.data.object as Stripe.Subscription;
|
||||
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
|
||||
const currentPeriodEnd = periodEndTimestamp
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error('Error processing webhook:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Webhook processing failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { headers } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.text();
|
||||
const signature = headers().get('stripe-signature');
|
||||
|
||||
if (!signature) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No signature' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Webhook signature verification failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid signature' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (session.mode === 'subscription') {
|
||||
const subscription: any = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string
|
||||
);
|
||||
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
|
||||
const currentPeriodEnd = periodEndTimestamp
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeCustomerId: session.customer as string,
|
||||
},
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
plan: (session.metadata?.plan || 'FREE') as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription: any = event.data.object as Stripe.Subscription;
|
||||
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
|
||||
const currentPeriodEnd = periodEndTimestamp
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error('Error processing webhook:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Webhook processing failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
database: user,
|
||||
localStorage: 'Check in browser console',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Debug error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
database: user,
|
||||
localStorage: 'Check in browser console',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Debug error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.ACCOUNT_DELETE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user data including Stripe information
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
stripeSubscriptionId: true,
|
||||
stripeCustomerId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Cancel Stripe subscription if user has one
|
||||
if (user.stripeSubscriptionId && user.plan !== 'FREE') {
|
||||
try {
|
||||
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
|
||||
} catch (stripeError) {
|
||||
console.error('Error canceling Stripe subscription:', stripeError);
|
||||
// Continue with deletion even if Stripe cancellation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user and all related data (cascading deletes should handle QR codes, scans, etc.)
|
||||
await db.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
// Clear auth cookie
|
||||
cookies().delete('userId');
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting account:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.ACCOUNT_DELETE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user data including Stripe information
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
stripeSubscriptionId: true,
|
||||
stripeCustomerId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Cancel Stripe subscription if user has one
|
||||
if (user.stripeSubscriptionId && user.plan !== 'FREE') {
|
||||
try {
|
||||
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
|
||||
} catch (stripeError) {
|
||||
console.error('Error canceling Stripe subscription:', stripeError);
|
||||
// Continue with deletion even if Stripe cancellation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user and all related data (cascading deletes should handle QR codes, scans, etc.)
|
||||
await db.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
// Clear auth cookie
|
||||
cookies().delete('userId');
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting account:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { changePasswordSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.PASSWORD_CHANGE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequest(changePasswordSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = validation.data;
|
||||
|
||||
// Get user with password
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user has a password (OAuth users don't have passwords)
|
||||
if (!user.password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot change password for OAuth accounts' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Current password is incorrect' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashedPassword },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error changing password:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { changePasswordSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: csrfCheck.error },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.PASSWORD_CHANGE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequest(changePasswordSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = validation.data;
|
||||
|
||||
// Get user with password
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user has a password (OAuth users don't have passwords)
|
||||
if (!user.password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot change password for OAuth accounts' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Current password is incorrect' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashedPassword },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error changing password:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { STRIPE_PLANS } from '@/lib/stripe';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Use cookie-based auth instead of NextAuth
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
plan: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
stripePriceId: true,
|
||||
stripeCustomerId: true,
|
||||
stripeSubscriptionId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Determine billing interval from stripePriceId
|
||||
let interval: 'month' | 'year' | null = null;
|
||||
|
||||
if (user.stripePriceId) {
|
||||
// Check if the current price ID matches any yearly price ID
|
||||
const isYearly =
|
||||
user.stripePriceId === STRIPE_PLANS.PRO.priceIdYearly ||
|
||||
user.stripePriceId === STRIPE_PLANS.BUSINESS.priceIdYearly;
|
||||
|
||||
interval = isYearly ? 'year' : 'month';
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
plan: user.plan || 'FREE',
|
||||
interval,
|
||||
currentPeriodEnd: user.stripeCurrentPeriodEnd,
|
||||
priceId: user.stripePriceId,
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
stripeSubscriptionId: user.stripeSubscriptionId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user plan:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { STRIPE_PLANS } from '@/lib/stripe';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Use cookie-based auth instead of NextAuth
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
plan: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
stripePriceId: true,
|
||||
stripeCustomerId: true,
|
||||
stripeSubscriptionId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Determine billing interval from stripePriceId
|
||||
let interval: 'month' | 'year' | null = null;
|
||||
|
||||
if (user.stripePriceId) {
|
||||
// Check if the current price ID matches any yearly price ID
|
||||
const isYearly =
|
||||
user.stripePriceId === STRIPE_PLANS.PRO.priceIdYearly ||
|
||||
user.stripePriceId === STRIPE_PLANS.BUSINESS.priceIdYearly;
|
||||
|
||||
interval = isYearly ? 'year' : 'month';
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
plan: user.plan || 'FREE',
|
||||
interval,
|
||||
currentPeriodEnd: user.stripeCurrentPeriodEnd,
|
||||
priceId: user.stripePriceId,
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
stripeSubscriptionId: user.stripeSubscriptionId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user plan:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequest(updateProfileSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const { name } = validation.data;
|
||||
|
||||
// Update user name in database
|
||||
const updatedUser = await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { name: name.trim() },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequest(updateProfileSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const { name } = validation.data;
|
||||
|
||||
// Update user name in database
|
||||
const updatedUser = await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { name: name.trim() },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// Force dynamic rendering (required for cookies)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/user
|
||||
* Get current user information
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// Force dynamic rendering (required for cookies)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/user
|
||||
* Get current user information
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user with plan info
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Count dynamic QR codes
|
||||
const dynamicQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'DYNAMIC',
|
||||
},
|
||||
});
|
||||
|
||||
// Count static QR codes
|
||||
const staticQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'STATIC',
|
||||
},
|
||||
});
|
||||
|
||||
// Determine limits based on plan
|
||||
let dynamicLimit = 3; // FREE plan default
|
||||
if (user.plan === 'PRO') {
|
||||
dynamicLimit = 50;
|
||||
} else if (user.plan === 'BUSINESS') {
|
||||
dynamicLimit = 500;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
dynamicUsed: dynamicQRCount,
|
||||
dynamicLimit,
|
||||
staticUsed: staticQRCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user with plan info
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Count dynamic QR codes
|
||||
const dynamicQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'DYNAMIC',
|
||||
},
|
||||
});
|
||||
|
||||
// Count static QR codes
|
||||
const staticQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'STATIC',
|
||||
},
|
||||
});
|
||||
|
||||
// Determine limits based on plan
|
||||
let dynamicLimit = 3; // FREE plan default
|
||||
if (user.plan === 'PRO') {
|
||||
dynamicLimit = 50;
|
||||
} else if (user.plan === 'BUSINESS') {
|
||||
dynamicLimit = 500;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
dynamicUsed: dynamicQRCount,
|
||||
dynamicLimit,
|
||||
staticUsed: staticQRCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const title = searchParams.get('title') || 'QR Master – Smart QR Generator & Analytics';
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#1a1a2e',
|
||||
backgroundImage: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px 80px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
textAlign: 'center',
|
||||
marginTop: 0,
|
||||
}}
|
||||
>
|
||||
Dynamic QR codes with analytics & branding
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return new Response('Failed to generate image', { status: 500 });
|
||||
}
|
||||
}
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const title = searchParams.get('title') || 'QR Master – Smart QR Generator & Analytics';
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#1a1a2e',
|
||||
backgroundImage: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px 80px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
textAlign: 'center',
|
||||
marginTop: 0,
|
||||
}}
|
||||
>
|
||||
Dynamic QR codes with analytics & branding
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return new Response('Failed to generate image', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -102,9 +102,7 @@ export default function QRCodeErstellenPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h1 className="sr-only">QR Code erstellen kostenlos mit Tracking und Branding</h1>
|
||||
|
||||
<Hero t={t} headingAs="div" />
|
||||
<Hero t={t} />
|
||||
|
||||
{/* Answer First Block (SEO/AEO) - German */}
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = 'https://www.qrmaster.net';
|
||||
const privatePaths = [
|
||||
'/api/',
|
||||
'/dashboard/',
|
||||
'/create/',
|
||||
'/settings/',
|
||||
'/cdn-cgi/',
|
||||
];
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: ['OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'anthropic-ai', 'Google-Extended'],
|
||||
allow: '/',
|
||||
disallow: privatePaths,
|
||||
},
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: privatePaths,
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = 'https://www.qrmaster.net';
|
||||
const privatePaths = [
|
||||
'/api/',
|
||||
'/dashboard/',
|
||||
'/create/',
|
||||
'/settings/',
|
||||
'/cdn-cgi/',
|
||||
];
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: ['OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'anthropic-ai', 'Google-Extended'],
|
||||
allow: '/',
|
||||
disallow: privatePaths,
|
||||
},
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: privatePaths,
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Breadcrumbs({ items, className }: BreadcrumbsProps) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={`mb-6 ${className || ''}`}>
|
||||
<ol className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.url} className="flex items-center">
|
||||
{index > 0 && <span className="mx-2">/</span>}
|
||||
{index === items.length - 1 ? (
|
||||
<span className="font-semibold text-gray-900" aria-current="page">
|
||||
{item.name}
|
||||
</span>
|
||||
) : (
|
||||
<Link href={item.url} className="hover:text-blue-600 transition-colors">
|
||||
{item.name}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export { type BreadcrumbItem as BreadcrumbItemType };
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Breadcrumbs({ items, className }: BreadcrumbsProps) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={`mb-6 ${className || ''}`}>
|
||||
<ol className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.url} className="flex items-center">
|
||||
{index > 0 && <span className="mx-2">/</span>}
|
||||
{index === items.length - 1 ? (
|
||||
<span className="font-semibold text-gray-900" aria-current="page">
|
||||
{item.name}
|
||||
</span>
|
||||
) : (
|
||||
<Link href={item.url} className="hover:text-blue-600 transition-colors">
|
||||
{item.name}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export { type BreadcrumbItem as BreadcrumbItemType };
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export default function CookieBanner() {
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already made a choice
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (!cookieConsent) {
|
||||
// Show banner after a short delay for better UX
|
||||
const timer = setTimeout(() => {
|
||||
setShowBanner(true);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
setShowBanner(false);
|
||||
// Reload page to initialize PostHog
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div suppressHydrationWarning>
|
||||
{showBanner && (
|
||||
<>
|
||||
{/* Cookie Banner - Bottom Left Corner */}
|
||||
<div className="fixed bottom-4 left-4 z-50 max-w-md animate-slide-in">
|
||||
<div className="bg-white rounded-lg shadow-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">
|
||||
We use cookies
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-3">
|
||||
We use essential cookies for authentication and analytics cookies to improve your experience.{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700 font-medium underline">
|
||||
Learn more about our privacy policy
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Cookie Categories */}
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center text-xs">
|
||||
<svg className="w-3.5 h-3.5 text-success-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700"><strong>Essential:</strong> Authentication, CSRF protection</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs">
|
||||
<svg className="w-3.5 h-3.5 text-primary-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700"><strong>Analytics:</strong> PostHog & Google Analytics</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDecline}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAccept}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Accept All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.4s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export default function CookieBanner() {
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already made a choice
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (!cookieConsent) {
|
||||
// Show banner after a short delay for better UX
|
||||
const timer = setTimeout(() => {
|
||||
setShowBanner(true);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
setShowBanner(false);
|
||||
// Reload page to initialize PostHog
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div suppressHydrationWarning>
|
||||
{showBanner && (
|
||||
<>
|
||||
{/* Cookie Banner - Bottom Left Corner */}
|
||||
<div className="fixed bottom-4 left-4 z-50 max-w-md animate-slide-in">
|
||||
<div className="bg-white rounded-lg shadow-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">
|
||||
We use cookies
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-3">
|
||||
We use essential cookies for authentication and analytics cookies to improve your experience.{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700 font-medium underline">
|
||||
Learn more about our privacy policy
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Cookie Categories */}
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center text-xs">
|
||||
<svg className="w-3.5 h-3.5 text-success-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700"><strong>Essential:</strong> Authentication, CSRF protection</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs">
|
||||
<svg className="w-3.5 h-3.5 text-primary-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700"><strong>Analytics:</strong> PostHog & Google Analytics</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDecline}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAccept}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Accept All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.4s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export default function AuthProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export default function AuthProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { TrendData } from '@/types/analytics';
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: {
|
||||
totalScans: number;
|
||||
activeQRCodes: number;
|
||||
conversionRate: number;
|
||||
uniqueScans?: number;
|
||||
};
|
||||
trends?: {
|
||||
totalScans?: TrendData;
|
||||
comparisonPeriod?: 'week' | 'month';
|
||||
};
|
||||
}
|
||||
|
||||
export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Build trend display text
|
||||
const getTrendText = () => {
|
||||
if (!trends?.totalScans) {
|
||||
return 'No data yet';
|
||||
}
|
||||
|
||||
const trend = trends.totalScans;
|
||||
const sign = trend.isNegative ? '-' : '+';
|
||||
const period = trends.comparisonPeriod || 'period';
|
||||
const newLabel = trend.isNew ? ' (new)' : '';
|
||||
|
||||
return `${sign}${trend.percentage}%${newLabel} from last ${period}`;
|
||||
};
|
||||
|
||||
const getTrendType = (): 'positive' | 'negative' | 'neutral' => {
|
||||
if (!trends?.totalScans) return 'neutral';
|
||||
if (trends.totalScans.trend === 'up') return 'positive';
|
||||
if (trends.totalScans.trend === 'down') return 'negative';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('dashboard.stats.total_scans'),
|
||||
value: formatNumber(stats.totalScans),
|
||||
change: getTrendText(),
|
||||
changeType: getTrendType(),
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('dashboard.stats.active_codes'),
|
||||
value: stats.activeQRCodes.toString(),
|
||||
change: stats.activeQRCodes > 0 ? `${stats.activeQRCodes} active` : 'Create your first',
|
||||
changeType: stats.activeQRCodes > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Unique Users',
|
||||
value: formatNumber(stats.uniqueScans ?? 0),
|
||||
change: stats.totalScans > 0 ? `${stats.uniqueScans ?? 0} unique visitors` : 'No scans yet',
|
||||
changeType: (stats.uniqueScans ?? 0) > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{cards.map((card, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
|
||||
<p className={`text-sm mt-2 ${card.changeType === 'positive' ? 'text-success-600' :
|
||||
card.changeType === 'negative' ? 'text-red-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
{card.change}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">
|
||||
{card.icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { TrendData } from '@/types/analytics';
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: {
|
||||
totalScans: number;
|
||||
activeQRCodes: number;
|
||||
conversionRate: number;
|
||||
uniqueScans?: number;
|
||||
};
|
||||
trends?: {
|
||||
totalScans?: TrendData;
|
||||
comparisonPeriod?: 'week' | 'month';
|
||||
};
|
||||
}
|
||||
|
||||
export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Build trend display text
|
||||
const getTrendText = () => {
|
||||
if (!trends?.totalScans) {
|
||||
return 'No data yet';
|
||||
}
|
||||
|
||||
const trend = trends.totalScans;
|
||||
const sign = trend.isNegative ? '-' : '+';
|
||||
const period = trends.comparisonPeriod || 'period';
|
||||
const newLabel = trend.isNew ? ' (new)' : '';
|
||||
|
||||
return `${sign}${trend.percentage}%${newLabel} from last ${period}`;
|
||||
};
|
||||
|
||||
const getTrendType = (): 'positive' | 'negative' | 'neutral' => {
|
||||
if (!trends?.totalScans) return 'neutral';
|
||||
if (trends.totalScans.trend === 'up') return 'positive';
|
||||
if (trends.totalScans.trend === 'down') return 'negative';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('dashboard.stats.total_scans'),
|
||||
value: formatNumber(stats.totalScans),
|
||||
change: getTrendText(),
|
||||
changeType: getTrendType(),
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('dashboard.stats.active_codes'),
|
||||
value: stats.activeQRCodes.toString(),
|
||||
change: stats.activeQRCodes > 0 ? `${stats.activeQRCodes} active` : 'Create your first',
|
||||
changeType: stats.activeQRCodes > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Unique Users',
|
||||
value: formatNumber(stats.uniqueScans ?? 0),
|
||||
change: stats.totalScans > 0 ? `${stats.uniqueScans ?? 0} unique visitors` : 'No scans yet',
|
||||
changeType: (stats.uniqueScans ?? 0) > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{cards.map((card, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
|
||||
<p className={`text-sm mt-2 ${card.changeType === 'positive' ? 'text-success-600' :
|
||||
card.changeType === 'negative' ? 'text-red-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
{card.change}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">
|
||||
{card.icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,157 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { calculateContrast } from '@/lib/utils';
|
||||
|
||||
interface QRPreviewProps {
|
||||
content: string;
|
||||
style: {
|
||||
foregroundColor: string;
|
||||
backgroundColor: string;
|
||||
cornerStyle: 'square' | 'rounded';
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const QRPreview: React.FC<QRPreviewProps> = ({ content, style }) => {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const contrast = calculateContrast(style.foregroundColor, style.backgroundColor);
|
||||
const hasGoodContrast = contrast >= 4.5;
|
||||
|
||||
useEffect(() => {
|
||||
const generateQR = async () => {
|
||||
try {
|
||||
if (!content) {
|
||||
setQrDataUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
errorCorrectionLevel: 'M' as const,
|
||||
};
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(content, options);
|
||||
setQrDataUrl(dataUrl);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Error generating QR code:', err);
|
||||
setError('Failed to generate QR code');
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [content, style]);
|
||||
|
||||
const downloadQR = async (format: 'svg' | 'png') => {
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
if (format === 'svg') {
|
||||
const svg = await QRCode.toString(content, {
|
||||
type: 'svg',
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrcode.svg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// For PNG, use the canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
await QRCode.toCanvas(canvas, content, {
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrcode.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error downloading QR code:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
{error ? (
|
||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
|
||||
{error}
|
||||
</div>
|
||||
) : qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code Preview"
|
||||
className={`border-2 border-gray-200 ${style.cornerStyle === 'rounded' ? 'rounded-lg' : ''}`}
|
||||
style={{ width: Math.min(style.size, 300), height: Math.min(style.size, 300) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
|
||||
Enter content to generate QR code
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
|
||||
{hasGoodContrast ? 'Good contrast' : 'Low contrast'}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">
|
||||
Contrast: {contrast.toFixed(1)}:1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => downloadQR('svg')}
|
||||
disabled={!content || !qrDataUrl}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Download SVG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadQR('png')}
|
||||
disabled={!content || !qrDataUrl}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { calculateContrast } from '@/lib/utils';
|
||||
|
||||
interface QRPreviewProps {
|
||||
content: string;
|
||||
style: {
|
||||
foregroundColor: string;
|
||||
backgroundColor: string;
|
||||
cornerStyle: 'square' | 'rounded';
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const QRPreview: React.FC<QRPreviewProps> = ({ content, style }) => {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const contrast = calculateContrast(style.foregroundColor, style.backgroundColor);
|
||||
const hasGoodContrast = contrast >= 4.5;
|
||||
|
||||
useEffect(() => {
|
||||
const generateQR = async () => {
|
||||
try {
|
||||
if (!content) {
|
||||
setQrDataUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
errorCorrectionLevel: 'M' as const,
|
||||
};
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(content, options);
|
||||
setQrDataUrl(dataUrl);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Error generating QR code:', err);
|
||||
setError('Failed to generate QR code');
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [content, style]);
|
||||
|
||||
const downloadQR = async (format: 'svg' | 'png') => {
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
if (format === 'svg') {
|
||||
const svg = await QRCode.toString(content, {
|
||||
type: 'svg',
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrcode.svg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// For PNG, use the canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
await QRCode.toCanvas(canvas, content, {
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrcode.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error downloading QR code:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
{error ? (
|
||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
|
||||
{error}
|
||||
</div>
|
||||
) : qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code Preview"
|
||||
className={`border-2 border-gray-200 ${style.cornerStyle === 'rounded' ? 'rounded-lg' : ''}`}
|
||||
style={{ width: Math.min(style.size, 300), height: Math.min(style.size, 300) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
|
||||
Enter content to generate QR code
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
|
||||
{hasGoodContrast ? 'Good contrast' : 'Low contrast'}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">
|
||||
Contrast: {contrast.toFixed(1)}:1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => downloadQR('svg')}
|
||||
disabled={!content || !qrDataUrl}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Download SVG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadQR('png')}
|
||||
disabled={!content || !qrDataUrl}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +1,73 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { TrackedCtaLink } from "@/components/marketing/MarketingAnalytics";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
type PageType = "commercial" | "use_case_hub" | "use_case";
|
||||
|
||||
type GrowthLink = {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
ctaLabel: string;
|
||||
};
|
||||
|
||||
export function GrowthLinksSection({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
links,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
links: GrowthLink[];
|
||||
pageType: PageType;
|
||||
cluster: string;
|
||||
useCase?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="py-20 bg-slate-50">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="max-w-3xl mb-12">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-3 text-4xl font-bold text-slate-900">{title}</h2>
|
||||
<p className="mt-4 text-xl text-slate-600">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-4">
|
||||
{links.map((link) => (
|
||||
<TrackedCtaLink
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
ctaLabel={link.ctaLabel}
|
||||
ctaLocation="related_workflows"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
className="group block h-full"
|
||||
>
|
||||
<Card className="h-full rounded-3xl border-slate-200 bg-white p-7 shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg">
|
||||
<div className="text-lg font-semibold text-slate-900">
|
||||
{link.title}
|
||||
</div>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{link.description}
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<span>Open workflow</span>
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Card>
|
||||
</TrackedCtaLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { TrackedCtaLink } from "@/components/marketing/MarketingAnalytics";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
type PageType = "commercial" | "use_case_hub" | "use_case";
|
||||
|
||||
type GrowthLink = {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
ctaLabel: string;
|
||||
};
|
||||
|
||||
export function GrowthLinksSection({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
links,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
links: GrowthLink[];
|
||||
pageType: PageType;
|
||||
cluster: string;
|
||||
useCase?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="py-20 bg-slate-50">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="max-w-3xl mb-12">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-3 text-4xl font-bold text-slate-900">{title}</h2>
|
||||
<p className="mt-4 text-xl text-slate-600">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-4">
|
||||
{links.map((link) => (
|
||||
<TrackedCtaLink
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
ctaLabel={link.ctaLabel}
|
||||
ctaLocation="related_workflows"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
className="group block h-full"
|
||||
>
|
||||
<Card className="h-full rounded-3xl border-slate-200 bg-white p-7 shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg">
|
||||
<div className="text-lg font-semibold text-slate-900">
|
||||
{link.title}
|
||||
</div>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{link.description}
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<span>Open workflow</span>
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Card>
|
||||
</TrackedCtaLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { trackEvent } from "@/components/PostHogProvider";
|
||||
|
||||
type PageType = "commercial" | "use_case_hub" | "use_case";
|
||||
|
||||
type TrackingContext = {
|
||||
pageType: PageType;
|
||||
cluster?: string;
|
||||
useCase?: string;
|
||||
};
|
||||
|
||||
function getUtmProperties(searchParams: ReturnType<typeof useSearchParams>) {
|
||||
return {
|
||||
utm_source: searchParams?.get("utm_source") || undefined,
|
||||
utm_medium: searchParams?.get("utm_medium") || undefined,
|
||||
utm_campaign: searchParams?.get("utm_campaign") || undefined,
|
||||
utm_content: searchParams?.get("utm_content") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function MarketingPageTracker({
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: TrackingContext) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent("landing_page_viewed", {
|
||||
landing_page_slug: pathname,
|
||||
page_type: pageType,
|
||||
cluster,
|
||||
use_case: useCase,
|
||||
...getUtmProperties(searchParams),
|
||||
});
|
||||
}, [cluster, pageType, pathname, searchParams, useCase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type TrackedCtaLinkProps = TrackingContext & {
|
||||
href: string;
|
||||
ctaLabel: string;
|
||||
ctaLocation: string;
|
||||
destination?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function TrackedCtaLink({
|
||||
href,
|
||||
ctaLabel,
|
||||
ctaLocation,
|
||||
destination,
|
||||
className,
|
||||
children,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: TrackedCtaLinkProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
trackEvent("cta_clicked", {
|
||||
landing_page_slug: pathname,
|
||||
page_type: pageType,
|
||||
cluster,
|
||||
use_case: useCase,
|
||||
cta_label: ctaLabel,
|
||||
cta_location: ctaLocation,
|
||||
destination: destination || href,
|
||||
...getUtmProperties(searchParams),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { trackEvent } from "@/components/PostHogProvider";
|
||||
|
||||
type PageType = "commercial" | "use_case_hub" | "use_case";
|
||||
|
||||
type TrackingContext = {
|
||||
pageType: PageType;
|
||||
cluster?: string;
|
||||
useCase?: string;
|
||||
};
|
||||
|
||||
function getUtmProperties(searchParams: ReturnType<typeof useSearchParams>) {
|
||||
return {
|
||||
utm_source: searchParams?.get("utm_source") || undefined,
|
||||
utm_medium: searchParams?.get("utm_medium") || undefined,
|
||||
utm_campaign: searchParams?.get("utm_campaign") || undefined,
|
||||
utm_content: searchParams?.get("utm_content") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function MarketingPageTracker({
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: TrackingContext) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent("landing_page_viewed", {
|
||||
landing_page_slug: pathname,
|
||||
page_type: pageType,
|
||||
cluster,
|
||||
use_case: useCase,
|
||||
...getUtmProperties(searchParams),
|
||||
});
|
||||
}, [cluster, pageType, pathname, searchParams, useCase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type TrackedCtaLinkProps = TrackingContext & {
|
||||
href: string;
|
||||
ctaLabel: string;
|
||||
ctaLocation: string;
|
||||
destination?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function TrackedCtaLink({
|
||||
href,
|
||||
ctaLabel,
|
||||
ctaLocation,
|
||||
destination,
|
||||
className,
|
||||
children,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: TrackedCtaLinkProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
trackEvent("cta_clicked", {
|
||||
landing_page_slug: pathname,
|
||||
page_type: pageType,
|
||||
cluster,
|
||||
use_case: useCase,
|
||||
cta_label: ctaLabel,
|
||||
cta_location: ctaLocation,
|
||||
destination: destination || href,
|
||||
...getUtmProperties(searchParams),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,132 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star, CheckCircle } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import type { Testimonial } from '@/lib/types';
|
||||
|
||||
interface TestimonialsProps {
|
||||
testimonials: Testimonial[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
export const Testimonials: React.FC<TestimonialsProps> = ({
|
||||
testimonials,
|
||||
title = "What Our Customers Say",
|
||||
subtitle = "Real experiences from businesses using QR Master",
|
||||
showAll = false
|
||||
}) => {
|
||||
const displayTestimonials = showAll ? testimonials : testimonials.slice(0, 3);
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return (
|
||||
<div className="flex gap-1" aria-label={`${rating} out of 5 stars`}>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`w-5 h-5 ${index < rating
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'fill-gray-200 text-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={`grid gap-8 ${displayTestimonials.length === 1
|
||||
? 'grid-cols-1 max-w-2xl mx-auto'
|
||||
: displayTestimonials.length === 2
|
||||
? 'grid-cols-1 md:grid-cols-2 max-w-4xl mx-auto'
|
||||
: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
||||
}`}>
|
||||
{displayTestimonials.map((testimonial, index) => (
|
||||
<motion.div
|
||||
key={testimonial.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Card hover className="h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{renderStars(testimonial.rating)}
|
||||
{testimonial.verified && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{testimonial.title}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<p className="text-gray-700 leading-relaxed mb-6">
|
||||
{testimonial.content}
|
||||
</p>
|
||||
<div className="border-t border-gray-200 pt-4 mt-auto">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{testimonial.author.name}
|
||||
</span>
|
||||
<div className="text-sm text-gray-600">
|
||||
{testimonial.author.company && (
|
||||
<span>{testimonial.author.company}</span>
|
||||
)}
|
||||
{testimonial.author.company && testimonial.author.location && (
|
||||
<span> • </span>
|
||||
)}
|
||||
{testimonial.author.location && (
|
||||
<span>{testimonial.author.location}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
{testimonial.date}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{!showAll && (
|
||||
<div className="mt-12 text-center">
|
||||
<a href="/testimonials" className="inline-flex items-center text-blue-600 font-semibold hover:text-blue-700 transition-colors">
|
||||
See all reviews
|
||||
<svg className="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section >
|
||||
);
|
||||
};
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star, CheckCircle } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import type { Testimonial } from '@/lib/types';
|
||||
|
||||
interface TestimonialsProps {
|
||||
testimonials: Testimonial[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
export const Testimonials: React.FC<TestimonialsProps> = ({
|
||||
testimonials,
|
||||
title = "What Our Customers Say",
|
||||
subtitle = "Real experiences from businesses using QR Master",
|
||||
showAll = false
|
||||
}) => {
|
||||
const displayTestimonials = showAll ? testimonials : testimonials.slice(0, 3);
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return (
|
||||
<div className="flex gap-1" aria-label={`${rating} out of 5 stars`}>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`w-5 h-5 ${index < rating
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'fill-gray-200 text-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={`grid gap-8 ${displayTestimonials.length === 1
|
||||
? 'grid-cols-1 max-w-2xl mx-auto'
|
||||
: displayTestimonials.length === 2
|
||||
? 'grid-cols-1 md:grid-cols-2 max-w-4xl mx-auto'
|
||||
: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
||||
}`}>
|
||||
{displayTestimonials.map((testimonial, index) => (
|
||||
<motion.div
|
||||
key={testimonial.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Card hover className="h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{renderStars(testimonial.rating)}
|
||||
{testimonial.verified && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{testimonial.title}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<p className="text-gray-700 leading-relaxed mb-6">
|
||||
{testimonial.content}
|
||||
</p>
|
||||
<div className="border-t border-gray-200 pt-4 mt-auto">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{testimonial.author.name}
|
||||
</span>
|
||||
<div className="text-sm text-gray-600">
|
||||
{testimonial.author.company && (
|
||||
<span>{testimonial.author.company}</span>
|
||||
)}
|
||||
{testimonial.author.company && testimonial.author.location && (
|
||||
<span> • </span>
|
||||
)}
|
||||
{testimonial.author.location && (
|
||||
<span>{testimonial.author.location}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
{testimonial.date}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{!showAll && (
|
||||
<div className="mt-12 text-center">
|
||||
<a href="/testimonials" className="inline-flex items-center text-blue-600 font-semibold hover:text-blue-700 transition-colors">
|
||||
See all reviews
|
||||
<svg className="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section >
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,427 +1,427 @@
|
||||
import type { FAQItem } from "@/lib/types";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Compass,
|
||||
Link2,
|
||||
Radar,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs, { BreadcrumbItem } from "@/components/Breadcrumbs";
|
||||
import SeoJsonLd from "@/components/SeoJsonLd";
|
||||
import { FAQSection } from "@/components/aeo/FAQSection";
|
||||
import {
|
||||
MarketingPageTracker,
|
||||
TrackedCtaLink,
|
||||
} from "@/components/marketing/MarketingAnalytics";
|
||||
import { AnswerFirstBlock } from "@/components/marketing/AnswerFirstBlock";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { breadcrumbSchema, faqPageSchema } from "@/lib/schema";
|
||||
|
||||
type LinkCard = {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type UseCasePageTemplateProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
eyebrow: string;
|
||||
intro: string;
|
||||
pageType: "commercial" | "use_case";
|
||||
cluster: string;
|
||||
useCase?: string;
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
answer: string;
|
||||
whenToUse: string[];
|
||||
comparisonItems: {
|
||||
label: string;
|
||||
value: boolean;
|
||||
text?: string;
|
||||
}[];
|
||||
howToSteps: string[];
|
||||
primaryCta: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
secondaryCta: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
workflowTitle: string;
|
||||
workflowIntro: string;
|
||||
workflowCards: {
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
checklistTitle: string;
|
||||
checklist: string[];
|
||||
supportLinks: LinkCard[];
|
||||
faq: FAQItem[];
|
||||
schemaData?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export function buildUseCaseMetadata({
|
||||
title,
|
||||
description,
|
||||
canonicalPath,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath: string;
|
||||
}): Metadata {
|
||||
const canonical = `https://www.qrmaster.net${canonicalPath}`;
|
||||
|
||||
return {
|
||||
title: {
|
||||
absolute: `${title} | QR Master`,
|
||||
},
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
languages: {
|
||||
"x-default": canonical,
|
||||
en: canonical,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | QR Master`,
|
||||
description,
|
||||
url: canonical,
|
||||
type: "website",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
twitter: {
|
||||
title: `${title} | QR Master`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function UseCasePageTemplate({
|
||||
title,
|
||||
description,
|
||||
eyebrow,
|
||||
intro,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
breadcrumbs,
|
||||
answer,
|
||||
whenToUse,
|
||||
comparisonItems,
|
||||
howToSteps,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
workflowTitle,
|
||||
workflowIntro,
|
||||
workflowCards,
|
||||
checklistTitle,
|
||||
checklist,
|
||||
supportLinks,
|
||||
faq,
|
||||
schemaData = [],
|
||||
}: UseCasePageTemplateProps) {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd
|
||||
data={[...schemaData, breadcrumbSchema(breadcrumbs), faqPageSchema(faq)]}
|
||||
/>
|
||||
<MarketingPageTracker
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-900 text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.22),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(255,255,255,0.08),transparent_30%)]" />
|
||||
<div className="relative container mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
|
||||
<Breadcrumbs
|
||||
items={breadcrumbs}
|
||||
className="[&_a]:text-blue-100/80 [&_a:hover]:text-white [&_span]:text-blue-100/80 [&_[aria-current=page]]:text-white"
|
||||
/>
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)] lg:items-center">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-cyan-100 shadow-lg shadow-cyan-950/30 backdrop-blur">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{eyebrow}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg leading-8 text-blue-50/88 md:text-xl">
|
||||
{intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm text-blue-50/80 sm:grid-cols-2">
|
||||
{[
|
||||
"Built for QR workflows where the printed surface should stay stable.",
|
||||
"Focused on operational clarity, not inflated ROI claims.",
|
||||
"Connected to a commercial parent and sibling workflows.",
|
||||
"Designed to fit QR Master's existing marketing theme.",
|
||||
].map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 backdrop-blur-sm"
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={primaryCta.href}
|
||||
ctaLabel={primaryCta.label}
|
||||
ctaLocation="hero_primary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-white px-8 py-4 text-base font-semibold text-slate-950 hover:bg-slate-100 sm:w-auto"
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href={secondaryCta.href}
|
||||
ctaLabel={secondaryCta.label}
|
||||
ctaLocation="hero_secondary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 px-8 py-4 text-base text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-white/10 bg-white/10 p-8 text-white shadow-2xl shadow-slate-950/30 backdrop-blur">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-cyan-200/70">
|
||||
Workflow snapshot
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">
|
||||
What matters here
|
||||
</div>
|
||||
</div>
|
||||
<Compass className="h-9 w-9 text-cyan-300" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{workflowCards.map((card, index) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/30 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-cyan-400/15 text-sm font-semibold text-cyan-200">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-blue-50/80">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<AnswerFirstBlock
|
||||
whatIsIt={answer}
|
||||
whenToUse={whenToUse}
|
||||
comparison={{
|
||||
leftTitle: "Static",
|
||||
rightTitle: "Better fit here",
|
||||
items: comparisonItems,
|
||||
}}
|
||||
howTo={{
|
||||
steps: howToSteps,
|
||||
}}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="bg-slate-50 py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
{workflowTitle}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-slate-600">
|
||||
{workflowIntro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{workflowCards.map((card) => (
|
||||
<Card
|
||||
key={card.title}
|
||||
className="rounded-3xl border-slate-200/80 bg-white p-7 shadow-sm"
|
||||
>
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-50 text-blue-700">
|
||||
<Radar className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{card.description}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto grid max-w-7xl gap-8 px-4 sm:px-6 lg:grid-cols-[minmax(0,0.95fr)_minmax(280px,0.8fr)] lg:px-8">
|
||||
<Card className="rounded-3xl border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
Checklist
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold text-slate-900">
|
||||
{checklistTitle}
|
||||
</h2>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-blue-700" />
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{checklist.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex items-start gap-3 text-slate-700"
|
||||
>
|
||||
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0 text-green-600" />
|
||||
<span className="leading-7">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-3xl border-slate-200 bg-slate-950 p-8 text-white shadow-xl shadow-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link2 className="h-5 w-5 text-cyan-300" />
|
||||
<h2 className="text-2xl font-bold">Related links</h2>
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
{supportLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group block rounded-2xl border border-white/10 bg-white/5 p-4 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{link.title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-blue-50/78">
|
||||
{link.description}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-cyan-300 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-5xl px-4 pb-6 sm:px-6 lg:px-8">
|
||||
<FAQSection items={faq} title={`${title} FAQ`} />
|
||||
</div>
|
||||
|
||||
<section className="pb-20 pt-6">
|
||||
<div className="container mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="rounded-[2rem] bg-gradient-to-r from-blue-700 via-indigo-700 to-slate-900 px-8 py-10 text-white shadow-2xl shadow-blue-100">
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-100/80">
|
||||
Next step
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">
|
||||
Use a QR workflow that stays useful after the print run starts.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-blue-50/84">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={primaryCta.href}
|
||||
ctaLabel={primaryCta.label}
|
||||
ctaLocation="footer_primary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-white px-7 text-slate-950 hover:bg-slate-100 sm:w-auto"
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href={secondaryCta.href}
|
||||
ctaLabel={secondaryCta.label}
|
||||
ctaLocation="footer_secondary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import type { FAQItem } from "@/lib/types";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Compass,
|
||||
Link2,
|
||||
Radar,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs, { BreadcrumbItem } from "@/components/Breadcrumbs";
|
||||
import SeoJsonLd from "@/components/SeoJsonLd";
|
||||
import { FAQSection } from "@/components/aeo/FAQSection";
|
||||
import {
|
||||
MarketingPageTracker,
|
||||
TrackedCtaLink,
|
||||
} from "@/components/marketing/MarketingAnalytics";
|
||||
import { AnswerFirstBlock } from "@/components/marketing/AnswerFirstBlock";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { breadcrumbSchema, faqPageSchema } from "@/lib/schema";
|
||||
|
||||
type LinkCard = {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type UseCasePageTemplateProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
eyebrow: string;
|
||||
intro: string;
|
||||
pageType: "commercial" | "use_case";
|
||||
cluster: string;
|
||||
useCase?: string;
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
answer: string;
|
||||
whenToUse: string[];
|
||||
comparisonItems: {
|
||||
label: string;
|
||||
value: boolean;
|
||||
text?: string;
|
||||
}[];
|
||||
howToSteps: string[];
|
||||
primaryCta: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
secondaryCta: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
workflowTitle: string;
|
||||
workflowIntro: string;
|
||||
workflowCards: {
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
checklistTitle: string;
|
||||
checklist: string[];
|
||||
supportLinks: LinkCard[];
|
||||
faq: FAQItem[];
|
||||
schemaData?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export function buildUseCaseMetadata({
|
||||
title,
|
||||
description,
|
||||
canonicalPath,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath: string;
|
||||
}): Metadata {
|
||||
const canonical = `https://www.qrmaster.net${canonicalPath}`;
|
||||
|
||||
return {
|
||||
title: {
|
||||
absolute: `${title} | QR Master`,
|
||||
},
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
languages: {
|
||||
"x-default": canonical,
|
||||
en: canonical,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | QR Master`,
|
||||
description,
|
||||
url: canonical,
|
||||
type: "website",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
twitter: {
|
||||
title: `${title} | QR Master`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function UseCasePageTemplate({
|
||||
title,
|
||||
description,
|
||||
eyebrow,
|
||||
intro,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
breadcrumbs,
|
||||
answer,
|
||||
whenToUse,
|
||||
comparisonItems,
|
||||
howToSteps,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
workflowTitle,
|
||||
workflowIntro,
|
||||
workflowCards,
|
||||
checklistTitle,
|
||||
checklist,
|
||||
supportLinks,
|
||||
faq,
|
||||
schemaData = [],
|
||||
}: UseCasePageTemplateProps) {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd
|
||||
data={[...schemaData, breadcrumbSchema(breadcrumbs), faqPageSchema(faq)]}
|
||||
/>
|
||||
<MarketingPageTracker
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-900 text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.22),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(255,255,255,0.08),transparent_30%)]" />
|
||||
<div className="relative container mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
|
||||
<Breadcrumbs
|
||||
items={breadcrumbs}
|
||||
className="[&_a]:text-blue-100/80 [&_a:hover]:text-white [&_span]:text-blue-100/80 [&_[aria-current=page]]:text-white"
|
||||
/>
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)] lg:items-center">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-cyan-100 shadow-lg shadow-cyan-950/30 backdrop-blur">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{eyebrow}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg leading-8 text-blue-50/88 md:text-xl">
|
||||
{intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm text-blue-50/80 sm:grid-cols-2">
|
||||
{[
|
||||
"Built for QR workflows where the printed surface should stay stable.",
|
||||
"Focused on operational clarity, not inflated ROI claims.",
|
||||
"Connected to a commercial parent and sibling workflows.",
|
||||
"Designed to fit QR Master's existing marketing theme.",
|
||||
].map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 backdrop-blur-sm"
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={primaryCta.href}
|
||||
ctaLabel={primaryCta.label}
|
||||
ctaLocation="hero_primary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-white px-8 py-4 text-base font-semibold text-slate-950 hover:bg-slate-100 sm:w-auto"
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href={secondaryCta.href}
|
||||
ctaLabel={secondaryCta.label}
|
||||
ctaLocation="hero_secondary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 px-8 py-4 text-base text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-white/10 bg-white/10 p-8 text-white shadow-2xl shadow-slate-950/30 backdrop-blur">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-cyan-200/70">
|
||||
Workflow snapshot
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">
|
||||
What matters here
|
||||
</div>
|
||||
</div>
|
||||
<Compass className="h-9 w-9 text-cyan-300" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{workflowCards.map((card, index) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/30 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-cyan-400/15 text-sm font-semibold text-cyan-200">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-blue-50/80">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<AnswerFirstBlock
|
||||
whatIsIt={answer}
|
||||
whenToUse={whenToUse}
|
||||
comparison={{
|
||||
leftTitle: "Static",
|
||||
rightTitle: "Better fit here",
|
||||
items: comparisonItems,
|
||||
}}
|
||||
howTo={{
|
||||
steps: howToSteps,
|
||||
}}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="bg-slate-50 py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
{workflowTitle}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-slate-600">
|
||||
{workflowIntro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{workflowCards.map((card) => (
|
||||
<Card
|
||||
key={card.title}
|
||||
className="rounded-3xl border-slate-200/80 bg-white p-7 shadow-sm"
|
||||
>
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-50 text-blue-700">
|
||||
<Radar className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{card.description}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto grid max-w-7xl gap-8 px-4 sm:px-6 lg:grid-cols-[minmax(0,0.95fr)_minmax(280px,0.8fr)] lg:px-8">
|
||||
<Card className="rounded-3xl border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
Checklist
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold text-slate-900">
|
||||
{checklistTitle}
|
||||
</h2>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-blue-700" />
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{checklist.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex items-start gap-3 text-slate-700"
|
||||
>
|
||||
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0 text-green-600" />
|
||||
<span className="leading-7">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-3xl border-slate-200 bg-slate-950 p-8 text-white shadow-xl shadow-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link2 className="h-5 w-5 text-cyan-300" />
|
||||
<h2 className="text-2xl font-bold">Related links</h2>
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
{supportLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group block rounded-2xl border border-white/10 bg-white/5 p-4 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{link.title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-blue-50/78">
|
||||
{link.description}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-cyan-300 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-5xl px-4 pb-6 sm:px-6 lg:px-8">
|
||||
<FAQSection items={faq} title={`${title} FAQ`} />
|
||||
</div>
|
||||
|
||||
<section className="pb-20 pt-6">
|
||||
<div className="container mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="rounded-[2rem] bg-gradient-to-r from-blue-700 via-indigo-700 to-slate-900 px-8 py-10 text-white shadow-2xl shadow-blue-100">
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-100/80">
|
||||
Next step
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">
|
||||
Use a QR workflow that stays useful after the print run starts.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-blue-50/84">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={primaryCta.href}
|
||||
ctaLabel={primaryCta.label}
|
||||
ctaLocation="footer_primary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-white px-7 text-slate-950 hover:bg-slate-100 sm:w-auto"
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href={secondaryCta.href}
|
||||
ctaLabel={secondaryCta.label}
|
||||
ctaLocation="footer_secondary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,165 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function ChangePasswordModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ChangePasswordModalProps) {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
showToast('Please fill in all fields', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
showToast('New password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast('New passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
showToast('New password must be different from current password', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/user/password', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to change password');
|
||||
}
|
||||
|
||||
showToast('Password changed successfully!', 'success');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
showToast(error.message || 'Failed to change password', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Change Password</h2>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Enter your current password and choose a new one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter new password (min. 8 characters)"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Updating...' : 'Update Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function ChangePasswordModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ChangePasswordModalProps) {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
showToast('Please fill in all fields', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
showToast('New password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast('New passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
showToast('New password must be different from current password', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/user/password', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to change password');
|
||||
}
|
||||
|
||||
showToast('Password changed successfully!', 'success');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
showToast(error.message || 'Failed to change password', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Change Password</h2>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Enter your current password and choose a new one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter new password (min. 8 characters)"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Updating...' : 'Update Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'success' | 'warning' | 'info' | 'error';
|
||||
}
|
||||
|
||||
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'bg-gray-100 text-gray-800',
|
||||
success: 'bg-success-100 text-success-800',
|
||||
warning: 'bg-warning-100 text-warning-800',
|
||||
info: 'bg-info-100 text-info-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'success' | 'warning' | 'info' | 'error';
|
||||
}
|
||||
|
||||
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'bg-gray-100 text-gray-800',
|
||||
success: 'bg-success-100 text-success-800',
|
||||
warning: 'bg-warning-100 text-warning-800',
|
||||
info: 'bg-info-100 text-info-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
@@ -1,38 +1,38 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BillingToggleProps {
|
||||
value: 'month' | 'year';
|
||||
onChange: (value: 'month' | 'year') => void;
|
||||
}
|
||||
|
||||
export const BillingToggle: React.FC<BillingToggleProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="inline-flex items-center rounded-lg border border-gray-300 bg-gray-50 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('month')}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
value === 'month'
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: 'bg-transparent text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('year')}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
value === 'year'
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: 'bg-transparent text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
Yearly
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BillingToggleProps {
|
||||
value: 'month' | 'year';
|
||||
onChange: (value: 'month' | 'year') => void;
|
||||
}
|
||||
|
||||
export const BillingToggle: React.FC<BillingToggleProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="inline-flex items-center rounded-lg border border-gray-300 bg-gray-50 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('month')}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
value === 'month'
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: 'bg-transparent text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('year')}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
value === 'year'
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: 'bg-transparent text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
Yearly
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
|
||||
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
|
||||
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(baseClasses, variants[variant], sizes[size], className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
|
||||
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
|
||||
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(baseClasses, variants[variant], sizes[size], className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
@@ -1,94 +1,94 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, hover = false, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-sm border border-gray-200 p-6',
|
||||
hover && 'transition-all duration-200 hover:shadow-md hover:border-gray-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
export const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-600', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center pt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, hover = false, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-sm border border-gray-200 p-6',
|
||||
hover && 'transition-all duration-200 hover:shadow-md hover:border-gray-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
export const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-600', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center pt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
@@ -1,92 +1,92 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<div className="relative z-50 w-full max-w-lg mx-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-lg border border-gray-200 p-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogContent.displayName = 'DialogContent';
|
||||
|
||||
interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogHeader = React.forwardRef<HTMLDivElement, DialogHeaderProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogTitle.displayName = 'DialogTitle';
|
||||
|
||||
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||
|
||||
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-600', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogDescription.displayName = 'DialogDescription';
|
||||
|
||||
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 pt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<div className="relative z-50 w-full max-w-lg mx-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-lg border border-gray-200 p-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogContent.displayName = 'DialogContent';
|
||||
|
||||
interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogHeader = React.forwardRef<HTMLDivElement, DialogHeaderProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogTitle.displayName = 'DialogTitle';
|
||||
|
||||
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||
|
||||
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-600', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogDescription.displayName = 'DialogDescription';
|
||||
|
||||
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 pt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
@@ -1,63 +1,63 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const Dropdown: React.FC<DropdownProps> = ({ trigger, children, align = 'left' }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div onClick={() => setIsOpen(!isOpen)}>
|
||||
{trigger}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-full mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50',
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
|
||||
({ className, icon, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="mr-2">{icon}</span>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const Dropdown: React.FC<DropdownProps> = ({ trigger, children, align = 'left' }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div onClick={() => setIsOpen(!isOpen)}>
|
||||
{trigger}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-full mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50',
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
|
||||
({ className, icon, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="mr-2">{icon}</span>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
DropdownItem.displayName = 'DropdownItem';
|
||||
@@ -1,52 +1,52 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, onInvalid, id, ...props }, ref) => {
|
||||
// Generate a unique id for accessibility if not provided
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
// Default English validation message
|
||||
const handleInvalid = (e: React.InvalidEvent<HTMLInputElement>) => {
|
||||
e.target.setCustomValidity('Please fill out this field.');
|
||||
if (onInvalid) onInvalid(e);
|
||||
};
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
e.currentTarget.setCustomValidity('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
onInvalid={handleInvalid}
|
||||
onInput={handleInput}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, onInvalid, id, ...props }, ref) => {
|
||||
// Generate a unique id for accessibility if not provided
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
// Default English validation message
|
||||
const handleInvalid = (e: React.InvalidEvent<HTMLInputElement>) => {
|
||||
e.target.setCustomValidity('Please fill out this field.');
|
||||
if (onInvalid) onInvalid(e);
|
||||
};
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
e.currentTarget.setCustomValidity('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
onInvalid={handleInvalid}
|
||||
onInput={handleInput}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
@@ -1,54 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface QRCodeProps {
|
||||
value: string;
|
||||
size?: number;
|
||||
fgColor?: string;
|
||||
bgColor?: string;
|
||||
level?: 'L' | 'M' | 'Q' | 'H';
|
||||
includeMargin?: boolean;
|
||||
imageSettings?: {
|
||||
src: string;
|
||||
height: number;
|
||||
width: number;
|
||||
excavate: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const QRCode: React.FC<QRCodeProps> = ({
|
||||
value,
|
||||
size = 128,
|
||||
fgColor = '#000000',
|
||||
bgColor = '#FFFFFF',
|
||||
level = 'M',
|
||||
includeMargin = false,
|
||||
imageSettings,
|
||||
}) => {
|
||||
if (!value) {
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-200 flex items-center justify-center text-gray-500"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QRCodeSVG
|
||||
value={value}
|
||||
size={size}
|
||||
fgColor={fgColor}
|
||||
bgColor={bgColor}
|
||||
level={level}
|
||||
includeMargin={includeMargin}
|
||||
imageSettings={imageSettings}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface QRCodeProps {
|
||||
value: string;
|
||||
size?: number;
|
||||
fgColor?: string;
|
||||
bgColor?: string;
|
||||
level?: 'L' | 'M' | 'Q' | 'H';
|
||||
includeMargin?: boolean;
|
||||
imageSettings?: {
|
||||
src: string;
|
||||
height: number;
|
||||
width: number;
|
||||
excavate: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const QRCode: React.FC<QRCodeProps> = ({
|
||||
value,
|
||||
size = 128,
|
||||
fgColor = '#000000',
|
||||
bgColor = '#FFFFFF',
|
||||
level = 'M',
|
||||
includeMargin = false,
|
||||
imageSettings,
|
||||
}) => {
|
||||
if (!value) {
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-200 flex items-center justify-center text-gray-500"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QRCodeSVG
|
||||
value={value}
|
||||
size={size}
|
||||
fgColor={fgColor}
|
||||
bgColor={bgColor}
|
||||
level={level}
|
||||
includeMargin={includeMargin}
|
||||
imageSettings={imageSettings}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRCode;
|
||||
@@ -1,57 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export function ScrollToTop() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Show button when page is scrolled down
|
||||
useEffect(() => {
|
||||
const toggleVisibility = () => {
|
||||
if (window.pageYOffset > 300) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', toggleVisibility);
|
||||
|
||||
return () => window.removeEventListener('scroll', toggleVisibility);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVisible && (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-8 right-8 z-50 p-4 bg-primary-600 hover:bg-primary-700 text-white rounded-full shadow-lg transition-all duration-300 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
aria-label="Scroll to top"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export function ScrollToTop() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Show button when page is scrolled down
|
||||
useEffect(() => {
|
||||
const toggleVisibility = () => {
|
||||
if (window.pageYOffset > 300) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', toggleVisibility);
|
||||
|
||||
return () => window.removeEventListener('scroll', toggleVisibility);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVisible && (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-8 right-8 z-50 p-4 bg-primary-600 hover:bg-primary-700 text-white rounded-full shadow-lg transition-all duration-300 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
aria-label="Scroll to top"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, options, id, ...props }, ref) => {
|
||||
// Generate a unique id for accessibility if not provided
|
||||
const selectId = id || (label ? `select-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, options, id, ...props }, ref) => {
|
||||
// Generate a unique id for accessibility if not provided
|
||||
const selectId = id || (label ? `select-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
@@ -1,105 +1,105 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-gray-50/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-gray-50/50 data-[state=selected]:bg-gray-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-gray-500', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-gray-50/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-gray-50/50 data-[state=selected]:bg-gray-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-gray-500', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -1,140 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export interface ToastProps {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({
|
||||
id,
|
||||
message,
|
||||
type = 'info',
|
||||
duration = 3000,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onClose?.(), 300);
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
const icons = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'bg-success-50 text-success-900 border-success-200',
|
||||
error: 'bg-red-50 text-red-900 border-red-200',
|
||||
warning: 'bg-warning-50 text-warning-900 border-warning-200',
|
||||
info: 'bg-info-50 text-info-900 border-info-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center space-x-3 px-4 py-3 rounded-lg border shadow-lg
|
||||
${colors[type]}
|
||||
transition-all duration-300 transform
|
||||
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">{icons[type]}</div>
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onClose?.(), 300);
|
||||
}}
|
||||
className="ml-auto flex-shrink-0 hover:opacity-70"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast Container
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const [toasts, setToasts] = useState<ToastProps[]>([]);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
|
||||
const handleToast = (event: CustomEvent<Omit<ToastProps, 'id'>>) => {
|
||||
const newToast: ToastProps = {
|
||||
...event.detail,
|
||||
id: Date.now().toString(),
|
||||
};
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
};
|
||||
|
||||
window.addEventListener('toast' as any, handleToast);
|
||||
return () => window.removeEventListener('toast' as any, handleToast);
|
||||
}, []);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
{...toast}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to show toast
|
||||
export const showToast = (
|
||||
message: string,
|
||||
type: 'success' | 'error' | 'info' | 'warning' = 'info',
|
||||
duration = 3000
|
||||
) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const event = new CustomEvent('toast', {
|
||||
detail: { message, type, duration },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export interface ToastProps {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({
|
||||
id,
|
||||
message,
|
||||
type = 'info',
|
||||
duration = 3000,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onClose?.(), 300);
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
const icons = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'bg-success-50 text-success-900 border-success-200',
|
||||
error: 'bg-red-50 text-red-900 border-red-200',
|
||||
warning: 'bg-warning-50 text-warning-900 border-warning-200',
|
||||
info: 'bg-info-50 text-info-900 border-info-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center space-x-3 px-4 py-3 rounded-lg border shadow-lg
|
||||
${colors[type]}
|
||||
transition-all duration-300 transform
|
||||
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">{icons[type]}</div>
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onClose?.(), 300);
|
||||
}}
|
||||
className="ml-auto flex-shrink-0 hover:opacity-70"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast Container
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const [toasts, setToasts] = useState<ToastProps[]>([]);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
|
||||
const handleToast = (event: CustomEvent<Omit<ToastProps, 'id'>>) => {
|
||||
const newToast: ToastProps = {
|
||||
...event.detail,
|
||||
id: Date.now().toString(),
|
||||
};
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
};
|
||||
|
||||
window.addEventListener('toast' as any, handleToast);
|
||||
return () => window.removeEventListener('toast' as any, handleToast);
|
||||
}, []);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
{...toast}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to show toast
|
||||
export const showToast = (
|
||||
message: string,
|
||||
type: 'success' | 'error' | 'info' | 'warning' = 'info',
|
||||
duration = 3000
|
||||
) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const event = new CustomEvent('toast', {
|
||||
detail: { message, type, duration },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
@@ -1,62 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage CSRF token for client-side requests
|
||||
*/
|
||||
export function useCsrf() {
|
||||
const [csrfToken, setCsrfToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const response = await fetch('/api/csrf');
|
||||
const data = await response.json();
|
||||
setCsrfToken(data.csrfToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch CSRF token:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCsrfToken();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Helper function to add CSRF token to fetch headers
|
||||
*/
|
||||
const getHeaders = (additionalHeaders: HeadersInit = {}) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(additionalHeaders as Record<string, string>),
|
||||
};
|
||||
|
||||
if (csrfToken) {
|
||||
headers['x-csrf-token'] = csrfToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for fetch with automatic CSRF token injection
|
||||
*/
|
||||
const fetchWithCsrf = async (url: string, options: RequestInit = {}) => {
|
||||
const headers = getHeaders(options.headers as HeadersInit);
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
csrfToken,
|
||||
loading,
|
||||
getHeaders,
|
||||
fetchWithCsrf,
|
||||
};
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage CSRF token for client-side requests
|
||||
*/
|
||||
export function useCsrf() {
|
||||
const [csrfToken, setCsrfToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const response = await fetch('/api/csrf');
|
||||
const data = await response.json();
|
||||
setCsrfToken(data.csrfToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch CSRF token:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCsrfToken();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Helper function to add CSRF token to fetch headers
|
||||
*/
|
||||
const getHeaders = (additionalHeaders: HeadersInit = {}) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(additionalHeaders as Record<string, string>),
|
||||
};
|
||||
|
||||
if (csrfToken) {
|
||||
headers['x-csrf-token'] = csrfToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for fetch with automatic CSRF token injection
|
||||
*/
|
||||
const fetchWithCsrf = async (url: string, options: RequestInit = {}) => {
|
||||
const headers = getHeaders(options.headers as HeadersInit);
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
csrfToken,
|
||||
loading,
|
||||
getHeaders,
|
||||
fetchWithCsrf,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import en from '@/i18n/en.json';
|
||||
import de from '@/i18n/de.json';
|
||||
|
||||
type Locale = 'en' | 'de';
|
||||
|
||||
const translations = {
|
||||
en,
|
||||
de,
|
||||
};
|
||||
|
||||
export function useTranslation() {
|
||||
const [locale, setLocale] = useState<Locale>('en');
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage for saved locale
|
||||
const savedLocale = localStorage.getItem('locale') as Locale;
|
||||
if (savedLocale && (savedLocale === 'en' || savedLocale === 'de')) {
|
||||
setLocale(savedLocale);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const changeLocale = (newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
localStorage.setItem('locale', newLocale);
|
||||
};
|
||||
|
||||
const t = (key: string, options?: { returnObjects?: boolean }) => {
|
||||
const keys = key.split('.');
|
||||
let value: any = translations[locale];
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
// Fallback to English if key not found
|
||||
value = translations.en;
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
return key; // Return key if not found
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
t,
|
||||
locale,
|
||||
language: locale,
|
||||
setLocale: changeLocale,
|
||||
setLanguage: changeLocale,
|
||||
};
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import en from '@/i18n/en.json';
|
||||
import de from '@/i18n/de.json';
|
||||
|
||||
type Locale = 'en' | 'de';
|
||||
|
||||
const translations = {
|
||||
en,
|
||||
de,
|
||||
};
|
||||
|
||||
export function useTranslation() {
|
||||
const [locale, setLocale] = useState<Locale>('en');
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage for saved locale
|
||||
const savedLocale = localStorage.getItem('locale') as Locale;
|
||||
if (savedLocale && (savedLocale === 'en' || savedLocale === 'de')) {
|
||||
setLocale(savedLocale);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const changeLocale = (newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
localStorage.setItem('locale', newLocale);
|
||||
};
|
||||
|
||||
const t = (key: string, options?: { returnObjects?: boolean }) => {
|
||||
const keys = key.split('.');
|
||||
let value: any = translations[locale];
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
// Fallback to English if key not found
|
||||
value = translations.en;
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
return key; // Return key if not found
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
t,
|
||||
locale,
|
||||
language: locale,
|
||||
setLocale: changeLocale,
|
||||
setLanguage: changeLocale,
|
||||
};
|
||||
}
|
||||
170
src/lib/auth.ts
170
src/lib/auth.ts
@@ -1,86 +1,86 @@
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { db } from './db';
|
||||
import { comparePassword } from './hash';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(db) as any,
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await comparePassword(
|
||||
credentials.password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan || 'FREE',
|
||||
};
|
||||
},
|
||||
}),
|
||||
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||
? [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.plan = user.plan;
|
||||
}
|
||||
|
||||
// Update session support
|
||||
if (trigger === "update" && session?.plan) {
|
||||
token.plan = session.plan;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session?.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.plan = (token.plan as string) || 'FREE';
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
error: '/login',
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { db } from './db';
|
||||
import { comparePassword } from './hash';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(db) as any,
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await comparePassword(
|
||||
credentials.password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan || 'FREE',
|
||||
};
|
||||
},
|
||||
}),
|
||||
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||
? [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.plan = user.plan;
|
||||
}
|
||||
|
||||
// Update session support
|
||||
if (trigger === "update" && session?.plan) {
|
||||
token.plan = session.plan;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session?.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.plan = (token.plan as string) || 'FREE';
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
error: '/login',
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
};
|
||||
5084
src/lib/blog-data.ts
5084
src/lib/blog-data.ts
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,80 +1,80 @@
|
||||
import { ChartConfiguration } from 'chart.js';
|
||||
|
||||
export const defaultChartOptions: Partial<ChartConfiguration['options']> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: '#f3f4f6',
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function createLineChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
data,
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...defaultChartOptions,
|
||||
plugins: {
|
||||
...(defaultChartOptions?.plugins || {}),
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBarChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
|
||||
return {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
data,
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...defaultChartOptions,
|
||||
plugins: {
|
||||
...(defaultChartOptions?.plugins || {}),
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
import { ChartConfiguration } from 'chart.js';
|
||||
|
||||
export const defaultChartOptions: Partial<ChartConfiguration['options']> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: '#f3f4f6',
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function createLineChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
data,
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...defaultChartOptions,
|
||||
plugins: {
|
||||
...(defaultChartOptions?.plugins || {}),
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBarChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
|
||||
return {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
data,
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...defaultChartOptions,
|
||||
plugins: {
|
||||
...(defaultChartOptions?.plugins || {}),
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* Cookie configuration helpers
|
||||
* Automatically uses secure settings in production
|
||||
*/
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
/**
|
||||
* Get cookie options for authentication cookies
|
||||
*/
|
||||
export function getAuthCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction, // HTTPS only in production
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cookie options for CSRF tokens
|
||||
* Note: httpOnly is false so the client can read it, but we verify via double-submit pattern
|
||||
*/
|
||||
export function getCsrfCookieOptions() {
|
||||
return {
|
||||
httpOnly: false, // Client needs to read this token for the header
|
||||
secure: isProduction, // HTTPS only in production
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: '/', // Available on all paths
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production
|
||||
*/
|
||||
export function isProductionEnvironment(): boolean {
|
||||
return isProduction;
|
||||
}
|
||||
/**
|
||||
* Cookie configuration helpers
|
||||
* Automatically uses secure settings in production
|
||||
*/
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
/**
|
||||
* Get cookie options for authentication cookies
|
||||
*/
|
||||
export function getAuthCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction, // HTTPS only in production
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cookie options for CSRF tokens
|
||||
* Note: httpOnly is false so the client can read it, but we verify via double-submit pattern
|
||||
*/
|
||||
export function getCsrfCookieOptions() {
|
||||
return {
|
||||
httpOnly: false, // Client needs to read this token for the header
|
||||
secure: isProduction, // HTTPS only in production
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: '/', // Available on all paths
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production
|
||||
*/
|
||||
export function isProductionEnvironment(): boolean {
|
||||
return isProduction;
|
||||
}
|
||||
|
||||
158
src/lib/csrf.ts
158
src/lib/csrf.ts
@@ -1,79 +1,79 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getCsrfCookieOptions } from './cookieConfig';
|
||||
|
||||
const CSRF_TOKEN_COOKIE = 'csrf_token';
|
||||
const CSRF_TOKEN_HEADER = 'x-csrf-token';
|
||||
|
||||
/**
|
||||
* Generate a new CSRF token and set it as a cookie
|
||||
*/
|
||||
export function generateCsrfToken(): string {
|
||||
const token = uuidv4();
|
||||
|
||||
cookies().set(CSRF_TOKEN_COOKIE, token, getCsrfCookieOptions());
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSRF token from cookies
|
||||
*/
|
||||
export function getCsrfToken(): string | undefined {
|
||||
return cookies().get(CSRF_TOKEN_COOKIE)?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSRF token from request header against cookie
|
||||
*/
|
||||
export function validateCsrfToken(headerToken: string | null): boolean {
|
||||
if (!headerToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cookieToken = getCsrfToken();
|
||||
|
||||
if (!cookieToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
return cookieToken === headerToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF Protection middleware for API routes
|
||||
*/
|
||||
export function csrfProtection(request: Request): { valid: boolean; error?: string } {
|
||||
const method = request.method;
|
||||
|
||||
// Only protect state-changing methods
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const headerToken = request.headers.get(CSRF_TOKEN_HEADER);
|
||||
|
||||
if (!validateCsrfToken(headerToken)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid or missing CSRF token'
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token for client-side use
|
||||
* This should be called from a GET endpoint
|
||||
*/
|
||||
export function getOrCreateCsrfToken(): string {
|
||||
let token = getCsrfToken();
|
||||
|
||||
if (!token) {
|
||||
token = generateCsrfToken();
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
import { cookies } from 'next/headers';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getCsrfCookieOptions } from './cookieConfig';
|
||||
|
||||
const CSRF_TOKEN_COOKIE = 'csrf_token';
|
||||
const CSRF_TOKEN_HEADER = 'x-csrf-token';
|
||||
|
||||
/**
|
||||
* Generate a new CSRF token and set it as a cookie
|
||||
*/
|
||||
export function generateCsrfToken(): string {
|
||||
const token = uuidv4();
|
||||
|
||||
cookies().set(CSRF_TOKEN_COOKIE, token, getCsrfCookieOptions());
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSRF token from cookies
|
||||
*/
|
||||
export function getCsrfToken(): string | undefined {
|
||||
return cookies().get(CSRF_TOKEN_COOKIE)?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSRF token from request header against cookie
|
||||
*/
|
||||
export function validateCsrfToken(headerToken: string | null): boolean {
|
||||
if (!headerToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cookieToken = getCsrfToken();
|
||||
|
||||
if (!cookieToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
return cookieToken === headerToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF Protection middleware for API routes
|
||||
*/
|
||||
export function csrfProtection(request: Request): { valid: boolean; error?: string } {
|
||||
const method = request.method;
|
||||
|
||||
// Only protect state-changing methods
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const headerToken = request.headers.get(CSRF_TOKEN_HEADER);
|
||||
|
||||
if (!validateCsrfToken(headerToken)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid or missing CSRF token'
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token for client-side use
|
||||
* This should be called from a GET endpoint
|
||||
*/
|
||||
export function getOrCreateCsrfToken(): string {
|
||||
let token = getCsrfToken();
|
||||
|
||||
if (!token) {
|
||||
token = generateCsrfToken();
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
1068
src/lib/email.ts
1068
src/lib/email.ts
File diff suppressed because it is too large
Load Diff
108
src/lib/geo.ts
108
src/lib/geo.ts
@@ -1,55 +1,55 @@
|
||||
export function getCountryFromHeaders(headers: Headers): string | null {
|
||||
// Try Vercel's country header first
|
||||
const vercelCountry = headers.get('x-vercel-ip-country');
|
||||
if (vercelCountry) {
|
||||
return vercelCountry;
|
||||
}
|
||||
|
||||
// Try Cloudflare's country header
|
||||
const cfCountry = headers.get('cf-ipcountry');
|
||||
if (cfCountry && cfCountry !== 'XX') {
|
||||
return cfCountry;
|
||||
}
|
||||
|
||||
// Fallback to other common headers
|
||||
const country = headers.get('x-country-code') || headers.get('x-forwarded-country');
|
||||
return country || null;
|
||||
}
|
||||
|
||||
export function parseUserAgent(userAgent: string | null): { device: string | null; os: string | null } {
|
||||
if (!userAgent) {
|
||||
return { device: null, os: null };
|
||||
}
|
||||
|
||||
let device: string | null = null;
|
||||
let os: string | null = null;
|
||||
|
||||
// Detect device
|
||||
// iPadOS 13+ sends "Macintosh" user agent.
|
||||
// Without referrer info here, we fall back to checking for Safari-only Mac UAs (common for iPad)
|
||||
const isIPad = /iPad/i.test(userAgent) ||
|
||||
(/Macintosh/i.test(userAgent) && /Safari/i.test(userAgent) && !/Chrome/i.test(userAgent));
|
||||
|
||||
if (isIPad || /Tablet|PlayBook|Silk/i.test(userAgent)) {
|
||||
device = 'tablet';
|
||||
} else if (/Mobile|Android|iPhone/i.test(userAgent) && !isIPad) {
|
||||
device = 'mobile';
|
||||
} else {
|
||||
device = 'desktop';
|
||||
}
|
||||
|
||||
// Detect OS
|
||||
if (/Windows/.test(userAgent)) {
|
||||
os = 'Windows';
|
||||
} else if (/Mac OS X|macOS/.test(userAgent)) {
|
||||
os = 'macOS';
|
||||
} else if (/Linux/.test(userAgent)) {
|
||||
os = 'Linux';
|
||||
} else if (/Android/.test(userAgent)) {
|
||||
os = 'Android';
|
||||
} else if (/iOS|iPhone|iPad/.test(userAgent)) {
|
||||
os = 'iOS';
|
||||
}
|
||||
|
||||
return { device, os };
|
||||
export function getCountryFromHeaders(headers: Headers): string | null {
|
||||
// Try Vercel's country header first
|
||||
const vercelCountry = headers.get('x-vercel-ip-country');
|
||||
if (vercelCountry) {
|
||||
return vercelCountry;
|
||||
}
|
||||
|
||||
// Try Cloudflare's country header
|
||||
const cfCountry = headers.get('cf-ipcountry');
|
||||
if (cfCountry && cfCountry !== 'XX') {
|
||||
return cfCountry;
|
||||
}
|
||||
|
||||
// Fallback to other common headers
|
||||
const country = headers.get('x-country-code') || headers.get('x-forwarded-country');
|
||||
return country || null;
|
||||
}
|
||||
|
||||
export function parseUserAgent(userAgent: string | null): { device: string | null; os: string | null } {
|
||||
if (!userAgent) {
|
||||
return { device: null, os: null };
|
||||
}
|
||||
|
||||
let device: string | null = null;
|
||||
let os: string | null = null;
|
||||
|
||||
// Detect device
|
||||
// iPadOS 13+ sends "Macintosh" user agent.
|
||||
// Without referrer info here, we fall back to checking for Safari-only Mac UAs (common for iPad)
|
||||
const isIPad = /iPad/i.test(userAgent) ||
|
||||
(/Macintosh/i.test(userAgent) && /Safari/i.test(userAgent) && !/Chrome/i.test(userAgent));
|
||||
|
||||
if (isIPad || /Tablet|PlayBook|Silk/i.test(userAgent)) {
|
||||
device = 'tablet';
|
||||
} else if (/Mobile|Android|iPhone/i.test(userAgent) && !isIPad) {
|
||||
device = 'mobile';
|
||||
} else {
|
||||
device = 'desktop';
|
||||
}
|
||||
|
||||
// Detect OS
|
||||
if (/Windows/.test(userAgent)) {
|
||||
os = 'Windows';
|
||||
} else if (/Mac OS X|macOS/.test(userAgent)) {
|
||||
os = 'macOS';
|
||||
} else if (/Linux/.test(userAgent)) {
|
||||
os = 'Linux';
|
||||
} else if (/Android/.test(userAgent)) {
|
||||
os = 'Android';
|
||||
} else if (/iOS|iPhone|iPad/.test(userAgent)) {
|
||||
os = 'iOS';
|
||||
}
|
||||
|
||||
return { device, os };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,49 @@
|
||||
import crypto from 'crypto';
|
||||
import { env } from './env';
|
||||
|
||||
/**
|
||||
* Hash an IP address for privacy
|
||||
* Uses a salt from environment variables to ensure consistent hashing
|
||||
*/
|
||||
export function hashIP(ip: string): string {
|
||||
const salt = env.IP_SALT || 'default-salt-change-in-production';
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(ip + salt)
|
||||
.digest('hex')
|
||||
.substring(0, 16); // Use first 16 chars for storage efficiency
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random slug for QR codes
|
||||
*/
|
||||
export function generateSlug(title?: string): string {
|
||||
const base = title
|
||||
? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 20)
|
||||
: 'qr';
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `${base}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure API key
|
||||
*/
|
||||
export function generateApiKey(): string {
|
||||
return 'qrm_' + crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password (for comparison with bcrypt hashed passwords)
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const bcrypt = await import('bcryptjs');
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a plain password with a hashed password
|
||||
*/
|
||||
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||
const bcrypt = await import('bcryptjs');
|
||||
return bcrypt.compare(password, hash);
|
||||
import crypto from 'crypto';
|
||||
import { env } from './env';
|
||||
|
||||
/**
|
||||
* Hash an IP address for privacy
|
||||
* Uses a salt from environment variables to ensure consistent hashing
|
||||
*/
|
||||
export function hashIP(ip: string): string {
|
||||
const salt = env.IP_SALT || 'default-salt-change-in-production';
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(ip + salt)
|
||||
.digest('hex')
|
||||
.substring(0, 16); // Use first 16 chars for storage efficiency
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random slug for QR codes
|
||||
*/
|
||||
export function generateSlug(title?: string): string {
|
||||
const base = title
|
||||
? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 20)
|
||||
: 'qr';
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `${base}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure API key
|
||||
*/
|
||||
export function generateApiKey(): string {
|
||||
return 'qrm_' + crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password (for comparison with bcrypt hashed passwords)
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const bcrypt = await import('bcryptjs');
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a plain password with a hashed password
|
||||
*/
|
||||
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||
const bcrypt = await import('bcryptjs');
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
452
src/lib/qr.ts
452
src/lib/qr.ts
@@ -1,227 +1,227 @@
|
||||
import { z } from 'zod';
|
||||
import QRCode from 'qrcode';
|
||||
import { db } from './db';
|
||||
import { generateSlug, hashIP } from './hash';
|
||||
import { getCountryFromHeaders, parseUserAgent } from './geo';
|
||||
import { ContentType, QRType, QRStatus } from '@prisma/client';
|
||||
import Redis from 'ioredis';
|
||||
import { env } from './env';
|
||||
|
||||
// Redis client (optional)
|
||||
let redis: Redis | null = null;
|
||||
if (env.REDIS_URL) {
|
||||
try {
|
||||
redis = new Redis(env.REDIS_URL);
|
||||
} catch (error) {
|
||||
console.warn('Redis connection failed, falling back to direct DB writes');
|
||||
}
|
||||
}
|
||||
|
||||
// Validation schemas
|
||||
const qrContentSchema = z.object({
|
||||
url: z.string().url().optional(),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
message: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
// VCARD fields
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
organization: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
// GEO fields
|
||||
latitude: z.number().optional(),
|
||||
longitude: z.number().optional(),
|
||||
label: z.string().optional(),
|
||||
});
|
||||
|
||||
const qrStyleSchema = z.object({
|
||||
foregroundColor: z.string().default('#000000'),
|
||||
backgroundColor: z.string().default('#FFFFFF'),
|
||||
cornerStyle: z.enum(['square', 'rounded']).default('square'),
|
||||
size: z.number().min(100).max(1000).default(200),
|
||||
});
|
||||
|
||||
const createQRSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
type: z.nativeEnum(QRType).default(QRType.DYNAMIC),
|
||||
contentType: z.nativeEnum(ContentType).default(ContentType.URL),
|
||||
content: qrContentSchema,
|
||||
tags: z.array(z.string()).default([]),
|
||||
style: qrStyleSchema.default({}),
|
||||
});
|
||||
|
||||
export async function createQR(userId: string, data: z.infer<typeof createQRSchema>) {
|
||||
const validated = createQRSchema.parse(data);
|
||||
|
||||
const slug = generateSlug(validated.title);
|
||||
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title: validated.title,
|
||||
type: validated.type,
|
||||
contentType: validated.contentType,
|
||||
content: validated.content,
|
||||
tags: validated.tags,
|
||||
style: validated.style,
|
||||
slug,
|
||||
status: QRStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
return qrCode;
|
||||
}
|
||||
|
||||
export async function updateQR(id: string, userId: string, data: Partial<z.infer<typeof createQRSchema>>) {
|
||||
const qrCode = await db.qRCode.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
throw new Error('QR Code not found');
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (data.title) updateData.title = data.title;
|
||||
if (data.content) updateData.content = data.content;
|
||||
if (data.tags) updateData.tags = data.tags;
|
||||
if (data.style) updateData.style = data.style;
|
||||
|
||||
return db.qRCode.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateQRCodeSVG(content: string, style: any = {}): Promise<string> {
|
||||
const options = {
|
||||
type: 'svg' as const,
|
||||
width: style.size || 200,
|
||||
color: {
|
||||
dark: style.foregroundColor || '#000000',
|
||||
light: style.backgroundColor || '#FFFFFF',
|
||||
},
|
||||
margin: 2,
|
||||
};
|
||||
|
||||
return QRCode.toString(content, options);
|
||||
}
|
||||
|
||||
export async function generateQRCodePNG(content: string, style: any = {}): Promise<Buffer> {
|
||||
const options = {
|
||||
width: style.size || 200,
|
||||
color: {
|
||||
dark: style.foregroundColor || '#000000',
|
||||
light: style.backgroundColor || '#FFFFFF',
|
||||
},
|
||||
margin: 2,
|
||||
};
|
||||
|
||||
return QRCode.toBuffer(content, options);
|
||||
}
|
||||
|
||||
export function getQRContent(qr: any): string {
|
||||
const { contentType, content } = qr;
|
||||
|
||||
switch (contentType) {
|
||||
case 'URL':
|
||||
return content.url || '';
|
||||
case 'PHONE':
|
||||
return `tel:${content.phone || ''}`;
|
||||
case 'SMS':
|
||||
const message = content.message ? `?body=${encodeURIComponent(content.message)}` : '';
|
||||
return `sms:${content.phone || ''}${message}`;
|
||||
case 'WHATSAPP':
|
||||
const whatsappMessage = content.message ? `?text=${encodeURIComponent(content.message)}` : '';
|
||||
return `https://wa.me/${content.phone || ''}${whatsappMessage}`;
|
||||
case 'VCARD':
|
||||
return `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${content.firstName || ''} ${content.lastName || ''}
|
||||
ORG:${content.organization || ''}
|
||||
TITLE:${content.title || ''}
|
||||
EMAIL:${content.email || ''}
|
||||
TEL:${content.phone || ''}
|
||||
END:VCARD`;
|
||||
case 'GEO':
|
||||
const lat = content.latitude || 0;
|
||||
const lon = content.longitude || 0;
|
||||
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
|
||||
return `geo:${lat},${lon}${label}`;
|
||||
case 'TEXT':
|
||||
return content.text || '';
|
||||
default:
|
||||
return content.url || '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function trackScan(qrId: string, request: Request) {
|
||||
const headers = request.headers;
|
||||
const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || '127.0.0.1';
|
||||
const userAgent = headers.get('user-agent');
|
||||
const referrer = headers.get('referer');
|
||||
const dnt = headers.get('dnt');
|
||||
|
||||
// Respect Do Not Track
|
||||
if (dnt === '1') {
|
||||
// Only increment aggregate counter, skip detailed tracking
|
||||
return;
|
||||
}
|
||||
|
||||
const ipHash = hashIP(ip);
|
||||
const country = getCountryFromHeaders(headers);
|
||||
const { device, os } = parseUserAgent(userAgent);
|
||||
|
||||
// Parse UTM parameters from referrer
|
||||
let utmSource: string | null = null;
|
||||
let utmMedium: string | null = null;
|
||||
let utmCampaign: string | null = null;
|
||||
|
||||
if (referrer) {
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
utmSource = url.searchParams.get('utm_source');
|
||||
utmMedium = url.searchParams.get('utm_medium');
|
||||
utmCampaign = url.searchParams.get('utm_campaign');
|
||||
} catch (e) {
|
||||
// Invalid referrer URL
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a unique scan (same IP hash within 24 hours)
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const existingScan = await db.qRScan.findFirst({
|
||||
where: {
|
||||
qrId,
|
||||
ipHash,
|
||||
ts: { gte: dayAgo },
|
||||
},
|
||||
});
|
||||
|
||||
const isUnique = !existingScan;
|
||||
|
||||
const scanData = {
|
||||
qrId,
|
||||
ipHash,
|
||||
userAgent,
|
||||
device,
|
||||
os,
|
||||
country,
|
||||
referrer,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
isUnique,
|
||||
};
|
||||
|
||||
// Fire-and-forget tracking
|
||||
if (redis) {
|
||||
// Queue to Redis for background processing
|
||||
redis.lpush('qr_scans', JSON.stringify(scanData)).catch(console.error);
|
||||
} else {
|
||||
// Direct database write
|
||||
db.qRScan.create({ data: scanData }).catch(console.error);
|
||||
}
|
||||
import { z } from 'zod';
|
||||
import QRCode from 'qrcode';
|
||||
import { db } from './db';
|
||||
import { generateSlug, hashIP } from './hash';
|
||||
import { getCountryFromHeaders, parseUserAgent } from './geo';
|
||||
import { ContentType, QRType, QRStatus } from '@prisma/client';
|
||||
import Redis from 'ioredis';
|
||||
import { env } from './env';
|
||||
|
||||
// Redis client (optional)
|
||||
let redis: Redis | null = null;
|
||||
if (env.REDIS_URL) {
|
||||
try {
|
||||
redis = new Redis(env.REDIS_URL);
|
||||
} catch (error) {
|
||||
console.warn('Redis connection failed, falling back to direct DB writes');
|
||||
}
|
||||
}
|
||||
|
||||
// Validation schemas
|
||||
const qrContentSchema = z.object({
|
||||
url: z.string().url().optional(),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
message: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
// VCARD fields
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
organization: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
// GEO fields
|
||||
latitude: z.number().optional(),
|
||||
longitude: z.number().optional(),
|
||||
label: z.string().optional(),
|
||||
});
|
||||
|
||||
const qrStyleSchema = z.object({
|
||||
foregroundColor: z.string().default('#000000'),
|
||||
backgroundColor: z.string().default('#FFFFFF'),
|
||||
cornerStyle: z.enum(['square', 'rounded']).default('square'),
|
||||
size: z.number().min(100).max(1000).default(200),
|
||||
});
|
||||
|
||||
const createQRSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
type: z.nativeEnum(QRType).default(QRType.DYNAMIC),
|
||||
contentType: z.nativeEnum(ContentType).default(ContentType.URL),
|
||||
content: qrContentSchema,
|
||||
tags: z.array(z.string()).default([]),
|
||||
style: qrStyleSchema.default({}),
|
||||
});
|
||||
|
||||
export async function createQR(userId: string, data: z.infer<typeof createQRSchema>) {
|
||||
const validated = createQRSchema.parse(data);
|
||||
|
||||
const slug = generateSlug(validated.title);
|
||||
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title: validated.title,
|
||||
type: validated.type,
|
||||
contentType: validated.contentType,
|
||||
content: validated.content,
|
||||
tags: validated.tags,
|
||||
style: validated.style,
|
||||
slug,
|
||||
status: QRStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
return qrCode;
|
||||
}
|
||||
|
||||
export async function updateQR(id: string, userId: string, data: Partial<z.infer<typeof createQRSchema>>) {
|
||||
const qrCode = await db.qRCode.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
throw new Error('QR Code not found');
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (data.title) updateData.title = data.title;
|
||||
if (data.content) updateData.content = data.content;
|
||||
if (data.tags) updateData.tags = data.tags;
|
||||
if (data.style) updateData.style = data.style;
|
||||
|
||||
return db.qRCode.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateQRCodeSVG(content: string, style: any = {}): Promise<string> {
|
||||
const options = {
|
||||
type: 'svg' as const,
|
||||
width: style.size || 200,
|
||||
color: {
|
||||
dark: style.foregroundColor || '#000000',
|
||||
light: style.backgroundColor || '#FFFFFF',
|
||||
},
|
||||
margin: 2,
|
||||
};
|
||||
|
||||
return QRCode.toString(content, options);
|
||||
}
|
||||
|
||||
export async function generateQRCodePNG(content: string, style: any = {}): Promise<Buffer> {
|
||||
const options = {
|
||||
width: style.size || 200,
|
||||
color: {
|
||||
dark: style.foregroundColor || '#000000',
|
||||
light: style.backgroundColor || '#FFFFFF',
|
||||
},
|
||||
margin: 2,
|
||||
};
|
||||
|
||||
return QRCode.toBuffer(content, options);
|
||||
}
|
||||
|
||||
export function getQRContent(qr: any): string {
|
||||
const { contentType, content } = qr;
|
||||
|
||||
switch (contentType) {
|
||||
case 'URL':
|
||||
return content.url || '';
|
||||
case 'PHONE':
|
||||
return `tel:${content.phone || ''}`;
|
||||
case 'SMS':
|
||||
const message = content.message ? `?body=${encodeURIComponent(content.message)}` : '';
|
||||
return `sms:${content.phone || ''}${message}`;
|
||||
case 'WHATSAPP':
|
||||
const whatsappMessage = content.message ? `?text=${encodeURIComponent(content.message)}` : '';
|
||||
return `https://wa.me/${content.phone || ''}${whatsappMessage}`;
|
||||
case 'VCARD':
|
||||
return `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${content.firstName || ''} ${content.lastName || ''}
|
||||
ORG:${content.organization || ''}
|
||||
TITLE:${content.title || ''}
|
||||
EMAIL:${content.email || ''}
|
||||
TEL:${content.phone || ''}
|
||||
END:VCARD`;
|
||||
case 'GEO':
|
||||
const lat = content.latitude || 0;
|
||||
const lon = content.longitude || 0;
|
||||
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
|
||||
return `geo:${lat},${lon}${label}`;
|
||||
case 'TEXT':
|
||||
return content.text || '';
|
||||
default:
|
||||
return content.url || '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function trackScan(qrId: string, request: Request) {
|
||||
const headers = request.headers;
|
||||
const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || '127.0.0.1';
|
||||
const userAgent = headers.get('user-agent');
|
||||
const referrer = headers.get('referer');
|
||||
const dnt = headers.get('dnt');
|
||||
|
||||
// Respect Do Not Track
|
||||
if (dnt === '1') {
|
||||
// Only increment aggregate counter, skip detailed tracking
|
||||
return;
|
||||
}
|
||||
|
||||
const ipHash = hashIP(ip);
|
||||
const country = getCountryFromHeaders(headers);
|
||||
const { device, os } = parseUserAgent(userAgent);
|
||||
|
||||
// Parse UTM parameters from referrer
|
||||
let utmSource: string | null = null;
|
||||
let utmMedium: string | null = null;
|
||||
let utmCampaign: string | null = null;
|
||||
|
||||
if (referrer) {
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
utmSource = url.searchParams.get('utm_source');
|
||||
utmMedium = url.searchParams.get('utm_medium');
|
||||
utmCampaign = url.searchParams.get('utm_campaign');
|
||||
} catch (e) {
|
||||
// Invalid referrer URL
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a unique scan (same IP hash within 24 hours)
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const existingScan = await db.qRScan.findFirst({
|
||||
where: {
|
||||
qrId,
|
||||
ipHash,
|
||||
ts: { gte: dayAgo },
|
||||
},
|
||||
});
|
||||
|
||||
const isUnique = !existingScan;
|
||||
|
||||
const scanData = {
|
||||
qrId,
|
||||
ipHash,
|
||||
userAgent,
|
||||
device,
|
||||
os,
|
||||
country,
|
||||
referrer,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
isUnique,
|
||||
};
|
||||
|
||||
// Fire-and-forget tracking
|
||||
if (redis) {
|
||||
// Queue to Redis for background processing
|
||||
redis.lpush('qr_scans', JSON.stringify(scanData)).catch(console.error);
|
||||
} else {
|
||||
// Direct database write
|
||||
db.qRScan.create({ data: scanData }).catch(console.error);
|
||||
}
|
||||
}
|
||||
@@ -1,229 +1,229 @@
|
||||
/**
|
||||
* Simple in-memory rate limiter
|
||||
* For production with multiple servers, consider using Redis/Upstash
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
// Store rate limit data in memory
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Cleanup old entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(rateLimitStore.entries());
|
||||
for (const [key, entry] of entries) {
|
||||
if (entry.resetAt < now) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
export interface RateLimitConfig {
|
||||
/**
|
||||
* Maximum number of requests allowed in the window
|
||||
*/
|
||||
maxRequests: number;
|
||||
|
||||
/**
|
||||
* Time window in seconds
|
||||
*/
|
||||
windowSeconds: number;
|
||||
|
||||
/**
|
||||
* Unique identifier for this rate limiter (e.g., 'login', 'signup')
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
success: boolean;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
reset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request should be rate limited
|
||||
*
|
||||
* @param identifier - Unique identifier for the client (e.g., IP address, email)
|
||||
* @param config - Rate limit configuration
|
||||
* @returns RateLimitResult
|
||||
*/
|
||||
export function rateLimit(
|
||||
identifier: string,
|
||||
config: RateLimitConfig
|
||||
): RateLimitResult {
|
||||
const key = `${config.name}:${identifier}`;
|
||||
const now = Date.now();
|
||||
const windowMs = config.windowSeconds * 1000;
|
||||
|
||||
let entry = rateLimitStore.get(key);
|
||||
|
||||
// Create new entry if doesn't exist or expired
|
||||
if (!entry || entry.resetAt < now) {
|
||||
entry = {
|
||||
count: 0,
|
||||
resetAt: now + windowMs,
|
||||
};
|
||||
rateLimitStore.set(key, entry);
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
entry.count++;
|
||||
|
||||
const remaining = Math.max(0, config.maxRequests - entry.count);
|
||||
const success = entry.count <= config.maxRequests;
|
||||
|
||||
return {
|
||||
success,
|
||||
limit: config.maxRequests,
|
||||
remaining,
|
||||
reset: entry.resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier from request (IP address)
|
||||
*/
|
||||
export function getClientIdentifier(request: Request): string {
|
||||
// Try to get real IP from headers (for proxies/load balancers)
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback (this won't work well in production behind a proxy)
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined rate limit configurations
|
||||
*/
|
||||
export const RateLimits = {
|
||||
// Auth endpoints
|
||||
// Login: 5 attempts per 15 minutes
|
||||
LOGIN: {
|
||||
name: 'login',
|
||||
maxRequests: 5,
|
||||
windowSeconds: 15 * 60,
|
||||
},
|
||||
|
||||
// Signup: 3 accounts per hour
|
||||
SIGNUP: {
|
||||
name: 'signup',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// Password reset: 3 attempts per hour
|
||||
PASSWORD_RESET: {
|
||||
name: 'password-reset',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// QR Code endpoints
|
||||
// Create QR: 20 per minute
|
||||
QR_CREATE: {
|
||||
name: 'qr-create',
|
||||
maxRequests: 20,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Modify QR: 30 per minute
|
||||
QR_MODIFY: {
|
||||
name: 'qr-modify',
|
||||
maxRequests: 30,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Delete all QRs: 3 per hour
|
||||
QR_DELETE_ALL: {
|
||||
name: 'qr-delete-all',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// Bulk create: 3 per hour
|
||||
BULK_CREATE: {
|
||||
name: 'bulk-create',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// User settings endpoints
|
||||
// Profile update: 10 per minute
|
||||
PROFILE_UPDATE: {
|
||||
name: 'profile-update',
|
||||
maxRequests: 10,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Password change: 5 per hour
|
||||
PASSWORD_CHANGE: {
|
||||
name: 'password-change',
|
||||
maxRequests: 5,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// Account delete: 2 per day
|
||||
ACCOUNT_DELETE: {
|
||||
name: 'account-delete',
|
||||
maxRequests: 2,
|
||||
windowSeconds: 24 * 60 * 60,
|
||||
},
|
||||
|
||||
// Analytics endpoints
|
||||
// Analytics summary: 30 per minute
|
||||
ANALYTICS: {
|
||||
name: 'analytics',
|
||||
maxRequests: 30,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Stripe endpoints
|
||||
// Checkout session: 5 per minute
|
||||
STRIPE_CHECKOUT: {
|
||||
name: 'stripe-checkout',
|
||||
maxRequests: 5,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Customer portal: 10 per minute
|
||||
STRIPE_PORTAL: {
|
||||
name: 'stripe-portal',
|
||||
maxRequests: 10,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Cancel subscription: 3 per hour
|
||||
STRIPE_CANCEL: {
|
||||
name: 'stripe-cancel',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// Newsletter endpoints
|
||||
// Newsletter subscribe: 5 per hour (prevent spam)
|
||||
NEWSLETTER_SUBSCRIBE: {
|
||||
name: 'newsletter-subscribe',
|
||||
maxRequests: 5,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// General API: 100 requests per minute
|
||||
API: {
|
||||
name: 'api',
|
||||
maxRequests: 100,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Simple in-memory rate limiter
|
||||
* For production with multiple servers, consider using Redis/Upstash
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
// Store rate limit data in memory
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Cleanup old entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(rateLimitStore.entries());
|
||||
for (const [key, entry] of entries) {
|
||||
if (entry.resetAt < now) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
export interface RateLimitConfig {
|
||||
/**
|
||||
* Maximum number of requests allowed in the window
|
||||
*/
|
||||
maxRequests: number;
|
||||
|
||||
/**
|
||||
* Time window in seconds
|
||||
*/
|
||||
windowSeconds: number;
|
||||
|
||||
/**
|
||||
* Unique identifier for this rate limiter (e.g., 'login', 'signup')
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
success: boolean;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
reset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request should be rate limited
|
||||
*
|
||||
* @param identifier - Unique identifier for the client (e.g., IP address, email)
|
||||
* @param config - Rate limit configuration
|
||||
* @returns RateLimitResult
|
||||
*/
|
||||
export function rateLimit(
|
||||
identifier: string,
|
||||
config: RateLimitConfig
|
||||
): RateLimitResult {
|
||||
const key = `${config.name}:${identifier}`;
|
||||
const now = Date.now();
|
||||
const windowMs = config.windowSeconds * 1000;
|
||||
|
||||
let entry = rateLimitStore.get(key);
|
||||
|
||||
// Create new entry if doesn't exist or expired
|
||||
if (!entry || entry.resetAt < now) {
|
||||
entry = {
|
||||
count: 0,
|
||||
resetAt: now + windowMs,
|
||||
};
|
||||
rateLimitStore.set(key, entry);
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
entry.count++;
|
||||
|
||||
const remaining = Math.max(0, config.maxRequests - entry.count);
|
||||
const success = entry.count <= config.maxRequests;
|
||||
|
||||
return {
|
||||
success,
|
||||
limit: config.maxRequests,
|
||||
remaining,
|
||||
reset: entry.resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier from request (IP address)
|
||||
*/
|
||||
export function getClientIdentifier(request: Request): string {
|
||||
// Try to get real IP from headers (for proxies/load balancers)
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback (this won't work well in production behind a proxy)
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined rate limit configurations
|
||||
*/
|
||||
export const RateLimits = {
|
||||
// Auth endpoints
|
||||
// Login: 5 attempts per 15 minutes
|
||||
LOGIN: {
|
||||
name: 'login',
|
||||
maxRequests: 5,
|
||||
windowSeconds: 15 * 60,
|
||||
},
|
||||
|
||||
// Signup: 3 accounts per hour
|
||||
SIGNUP: {
|
||||
name: 'signup',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// Password reset: 3 attempts per hour
|
||||
PASSWORD_RESET: {
|
||||
name: 'password-reset',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// QR Code endpoints
|
||||
// Create QR: 20 per minute
|
||||
QR_CREATE: {
|
||||
name: 'qr-create',
|
||||
maxRequests: 20,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Modify QR: 30 per minute
|
||||
QR_MODIFY: {
|
||||
name: 'qr-modify',
|
||||
maxRequests: 30,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Delete all QRs: 3 per hour
|
||||
QR_DELETE_ALL: {
|
||||
name: 'qr-delete-all',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// Bulk create: 3 per hour
|
||||
BULK_CREATE: {
|
||||
name: 'bulk-create',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// User settings endpoints
|
||||
// Profile update: 10 per minute
|
||||
PROFILE_UPDATE: {
|
||||
name: 'profile-update',
|
||||
maxRequests: 10,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Password change: 5 per hour
|
||||
PASSWORD_CHANGE: {
|
||||
name: 'password-change',
|
||||
maxRequests: 5,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// Account delete: 2 per day
|
||||
ACCOUNT_DELETE: {
|
||||
name: 'account-delete',
|
||||
maxRequests: 2,
|
||||
windowSeconds: 24 * 60 * 60,
|
||||
},
|
||||
|
||||
// Analytics endpoints
|
||||
// Analytics summary: 30 per minute
|
||||
ANALYTICS: {
|
||||
name: 'analytics',
|
||||
maxRequests: 30,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Stripe endpoints
|
||||
// Checkout session: 5 per minute
|
||||
STRIPE_CHECKOUT: {
|
||||
name: 'stripe-checkout',
|
||||
maxRequests: 5,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Customer portal: 10 per minute
|
||||
STRIPE_PORTAL: {
|
||||
name: 'stripe-portal',
|
||||
maxRequests: 10,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Cancel subscription: 3 per hour
|
||||
STRIPE_CANCEL: {
|
||||
name: 'stripe-cancel',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// Newsletter endpoints
|
||||
// Newsletter subscribe: 5 per hour (prevent spam)
|
||||
NEWSLETTER_SUBSCRIBE: {
|
||||
name: 'newsletter-subscribe',
|
||||
maxRequests: 5,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// General API: 100 requests per minute
|
||||
API: {
|
||||
name: 'api',
|
||||
maxRequests: 100,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,80 +1,80 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
// Use a placeholder during build time, real key at runtime
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
|
||||
|
||||
export const stripe = new Stripe(stripeKey, {
|
||||
apiVersion: '2025-10-29.clover',
|
||||
typescript: true,
|
||||
});
|
||||
|
||||
// Runtime validation (will throw when actually used in production if not set)
|
||||
export function validateStripeKey() {
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
throw new Error('STRIPE_SECRET_KEY is not set');
|
||||
}
|
||||
}
|
||||
|
||||
export const STRIPE_PLANS = {
|
||||
FREE: {
|
||||
name: 'Free / Starter',
|
||||
price: 0,
|
||||
currency: 'EUR',
|
||||
interval: 'month',
|
||||
features: [
|
||||
'3 dynamische QR-Codes',
|
||||
'Basis-Tracking (Scans + Standort)',
|
||||
'Einfache Designs',
|
||||
'Unbegrenzte statische QR-Codes',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 3,
|
||||
staticQRCodes: -1, // unlimited
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: null, // No Stripe price for free plan
|
||||
},
|
||||
PRO: {
|
||||
name: 'Pro',
|
||||
price: 9,
|
||||
priceYearly: 90,
|
||||
currency: 'EUR',
|
||||
interval: 'month',
|
||||
features: [
|
||||
'50 QR Codes',
|
||||
'Branding (Colors)',
|
||||
'Detailed Analytics (Date, Device, City)',
|
||||
'CSV Export',
|
||||
'SVG/PNG Download',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 50,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY,
|
||||
priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY,
|
||||
},
|
||||
BUSINESS: {
|
||||
name: 'Business',
|
||||
price: 29,
|
||||
priceYearly: 290,
|
||||
currency: 'EUR',
|
||||
interval: 'month',
|
||||
features: [
|
||||
'500 QR-Codes',
|
||||
'Everything from Pro',
|
||||
'Bulk QR Generation (up to 1,000)',
|
||||
'Priority Support',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 500,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
||||
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type PlanType = keyof typeof STRIPE_PLANS;
|
||||
import Stripe from 'stripe';
|
||||
|
||||
// Use a placeholder during build time, real key at runtime
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
|
||||
|
||||
export const stripe = new Stripe(stripeKey, {
|
||||
apiVersion: '2025-10-29.clover',
|
||||
typescript: true,
|
||||
});
|
||||
|
||||
// Runtime validation (will throw when actually used in production if not set)
|
||||
export function validateStripeKey() {
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
throw new Error('STRIPE_SECRET_KEY is not set');
|
||||
}
|
||||
}
|
||||
|
||||
export const STRIPE_PLANS = {
|
||||
FREE: {
|
||||
name: 'Free / Starter',
|
||||
price: 0,
|
||||
currency: 'EUR',
|
||||
interval: 'month',
|
||||
features: [
|
||||
'3 dynamische QR-Codes',
|
||||
'Basis-Tracking (Scans + Standort)',
|
||||
'Einfache Designs',
|
||||
'Unbegrenzte statische QR-Codes',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 3,
|
||||
staticQRCodes: -1, // unlimited
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: null, // No Stripe price for free plan
|
||||
},
|
||||
PRO: {
|
||||
name: 'Pro',
|
||||
price: 9,
|
||||
priceYearly: 90,
|
||||
currency: 'EUR',
|
||||
interval: 'month',
|
||||
features: [
|
||||
'50 QR Codes',
|
||||
'Branding (Colors)',
|
||||
'Detailed Analytics (Date, Device, City)',
|
||||
'CSV Export',
|
||||
'SVG/PNG Download',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 50,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY,
|
||||
priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY,
|
||||
},
|
||||
BUSINESS: {
|
||||
name: 'Business',
|
||||
price: 29,
|
||||
priceYearly: 290,
|
||||
currency: 'EUR',
|
||||
interval: 'month',
|
||||
features: [
|
||||
'500 QR-Codes',
|
||||
'Everything from Pro',
|
||||
'Bulk QR Generation (up to 1,000)',
|
||||
'Priority Support',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 500,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
||||
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type PlanType = keyof typeof STRIPE_PLANS;
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
export type Testimonial = {
|
||||
id: string;
|
||||
rating: number;
|
||||
title: string;
|
||||
content: string;
|
||||
author: {
|
||||
name: string;
|
||||
location?: string;
|
||||
company?: string;
|
||||
role?: string;
|
||||
};
|
||||
date: string;
|
||||
datePublished: string;
|
||||
verified: boolean;
|
||||
featured: boolean;
|
||||
useCase?: string;
|
||||
};
|
||||
|
||||
export type AggregateRating = {
|
||||
ratingValue: number;
|
||||
reviewCount: number;
|
||||
bestRating: number;
|
||||
worstRating: number;
|
||||
};
|
||||
|
||||
export const testimonials: Testimonial[] = [
|
||||
{
|
||||
id: "pottery-claudia-knuth-001",
|
||||
rating: 5,
|
||||
title: "Perfect for my pottery",
|
||||
content: "I use QR-Master for my pottery as a link to my homepage and as a digital business card. I place the codes directly on my pottery pieces so interested customers can instantly access my website. Reliable and practical – a great solution!",
|
||||
author: {
|
||||
name: "Claudia",
|
||||
company: "Hotshpotsh",
|
||||
location: "Texas"
|
||||
},
|
||||
date: "January 2026",
|
||||
datePublished: "2026-01-15T00:00:00Z",
|
||||
verified: true,
|
||||
featured: true,
|
||||
useCase: "pottery"
|
||||
}
|
||||
];
|
||||
|
||||
export function getAggregateRating(): AggregateRating {
|
||||
const ratings = testimonials.map(t => t.rating);
|
||||
const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length;
|
||||
return {
|
||||
ratingValue: Number(avgRating.toFixed(1)),
|
||||
reviewCount: testimonials.length,
|
||||
bestRating: 5,
|
||||
worstRating: 1
|
||||
};
|
||||
}
|
||||
|
||||
export function getFeaturedTestimonials(): Testimonial[] {
|
||||
return testimonials.filter(t => t.featured);
|
||||
}
|
||||
export type Testimonial = {
|
||||
id: string;
|
||||
rating: number;
|
||||
title: string;
|
||||
content: string;
|
||||
author: {
|
||||
name: string;
|
||||
location?: string;
|
||||
company?: string;
|
||||
role?: string;
|
||||
};
|
||||
date: string;
|
||||
datePublished: string;
|
||||
verified: boolean;
|
||||
featured: boolean;
|
||||
useCase?: string;
|
||||
};
|
||||
|
||||
export type AggregateRating = {
|
||||
ratingValue: number;
|
||||
reviewCount: number;
|
||||
bestRating: number;
|
||||
worstRating: number;
|
||||
};
|
||||
|
||||
export const testimonials: Testimonial[] = [
|
||||
{
|
||||
id: "pottery-claudia-knuth-001",
|
||||
rating: 5,
|
||||
title: "Perfect for my pottery",
|
||||
content: "I use QR-Master for my pottery as a link to my homepage and as a digital business card. I place the codes directly on my pottery pieces so interested customers can instantly access my website. Reliable and practical – a great solution!",
|
||||
author: {
|
||||
name: "Claudia",
|
||||
company: "Hotshpotsh",
|
||||
location: "Texas"
|
||||
},
|
||||
date: "January 2026",
|
||||
datePublished: "2026-01-15T00:00:00Z",
|
||||
verified: true,
|
||||
featured: true,
|
||||
useCase: "pottery"
|
||||
}
|
||||
];
|
||||
|
||||
export function getAggregateRating(): AggregateRating {
|
||||
const ratings = testimonials.map(t => t.rating);
|
||||
const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length;
|
||||
return {
|
||||
ratingValue: Number(avgRating.toFixed(1)),
|
||||
reviewCount: testimonials.length,
|
||||
bestRating: 5,
|
||||
worstRating: 1
|
||||
};
|
||||
}
|
||||
|
||||
export function getFeaturedTestimonials(): Testimonial[] {
|
||||
return testimonials.filter(t => t.featured);
|
||||
}
|
||||
|
||||
@@ -1,200 +1,200 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Floating blob animation for hero background */
|
||||
@keyframes blob {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translate(20px, -30px) scale(1.1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translate(30px, 10px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
.animation-delay-6000 {
|
||||
animation-delay: 6s;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 255, 255, 255;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply transition-all duration-200 hover:shadow-md hover:border-gray-300;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-primary-700 focus-ring transition-colors;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 text-gray-900 px-4 py-2 rounded-lg font-medium hover:bg-gray-200 focus-ring transition-colors;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-50 focus-ring transition-colors;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-warning-100 text-warning-800;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply bg-info-100 text-info-800;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-gray-200 rounded;
|
||||
}
|
||||
|
||||
/* Gradient backgrounds */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.gradient-success {
|
||||
background: linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%);
|
||||
}
|
||||
|
||||
/* Chart container */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Blog post spacing overrides */
|
||||
.prose h2 {
|
||||
margin-top: 1.75rem !important;
|
||||
margin-bottom: 0.875rem !important;
|
||||
padding-top: 0.875rem !important;
|
||||
font-size: 2.25rem !important;
|
||||
line-height: 2.5rem !important;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 0.875rem !important;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
margin-top: 0.875rem !important;
|
||||
margin-bottom: 0.875rem !important;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin-top: 1.25rem !important;
|
||||
margin-bottom: 1.25rem !important;
|
||||
}
|
||||
|
||||
.prose .lead {
|
||||
margin-bottom: 1.25rem !important;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Floating blob animation for hero background */
|
||||
@keyframes blob {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translate(20px, -30px) scale(1.1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translate(30px, 10px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
.animation-delay-6000 {
|
||||
animation-delay: 6s;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 255, 255, 255;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply transition-all duration-200 hover:shadow-md hover:border-gray-300;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-primary-700 focus-ring transition-colors;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 text-gray-900 px-4 py-2 rounded-lg font-medium hover:bg-gray-200 focus-ring transition-colors;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-50 focus-ring transition-colors;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-warning-100 text-warning-800;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply bg-info-100 text-info-800;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-gray-200 rounded;
|
||||
}
|
||||
|
||||
/* Gradient backgrounds */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.gradient-success {
|
||||
background: linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%);
|
||||
}
|
||||
|
||||
/* Chart container */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Blog post spacing overrides */
|
||||
.prose h2 {
|
||||
margin-top: 1.75rem !important;
|
||||
margin-bottom: 0.875rem !important;
|
||||
padding-top: 0.875rem !important;
|
||||
font-size: 2.25rem !important;
|
||||
line-height: 2.5rem !important;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 0.875rem !important;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
margin-top: 0.875rem !important;
|
||||
margin-bottom: 0.875rem !important;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin-top: 1.25rem !important;
|
||||
margin-bottom: 1.25rem !important;
|
||||
}
|
||||
|
||||
.prose .lead {
|
||||
margin-bottom: 1.25rem !important;
|
||||
}
|
||||
@@ -1,50 +1,50 @@
|
||||
export type TrendType = 'up' | 'down' | 'flat';
|
||||
|
||||
export interface TrendData {
|
||||
trend: TrendType;
|
||||
percentage: number;
|
||||
isNegative?: boolean;
|
||||
isNew?: boolean; // When growing from 0 previous data
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
totalScans: number;
|
||||
uniqueScans: number;
|
||||
avgScansPerQR: number;
|
||||
mobilePercentage: number;
|
||||
topCountry: string;
|
||||
topCountryPercentage: number;
|
||||
scansTrend?: TrendData;
|
||||
avgScansTrend?: TrendData;
|
||||
comparisonPeriod: 'week' | 'month';
|
||||
comparisonDays: number;
|
||||
}
|
||||
|
||||
export interface CountryStats {
|
||||
country: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
trend: TrendType;
|
||||
trendPercentage: number;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export interface QRPerformance {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
totalScans: number;
|
||||
uniqueScans: number;
|
||||
conversion: number;
|
||||
trend: TrendType;
|
||||
trendPercentage: number;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
summary: AnalyticsSummary;
|
||||
deviceStats: Record<string, number>;
|
||||
countryStats: CountryStats[];
|
||||
dailyScans: Record<string, number>;
|
||||
qrPerformance: QRPerformance[];
|
||||
}
|
||||
export type TrendType = 'up' | 'down' | 'flat';
|
||||
|
||||
export interface TrendData {
|
||||
trend: TrendType;
|
||||
percentage: number;
|
||||
isNegative?: boolean;
|
||||
isNew?: boolean; // When growing from 0 previous data
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
totalScans: number;
|
||||
uniqueScans: number;
|
||||
avgScansPerQR: number;
|
||||
mobilePercentage: number;
|
||||
topCountry: string;
|
||||
topCountryPercentage: number;
|
||||
scansTrend?: TrendData;
|
||||
avgScansTrend?: TrendData;
|
||||
comparisonPeriod: 'week' | 'month';
|
||||
comparisonDays: number;
|
||||
}
|
||||
|
||||
export interface CountryStats {
|
||||
country: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
trend: TrendType;
|
||||
trendPercentage: number;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export interface QRPerformance {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
totalScans: number;
|
||||
uniqueScans: number;
|
||||
conversion: number;
|
||||
trend: TrendType;
|
||||
trendPercentage: number;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
summary: AnalyticsSummary;
|
||||
deviceStats: Record<string, number>;
|
||||
countryStats: CountryStats[];
|
||||
dailyScans: Record<string, number>;
|
||||
qrPerformance: QRPerformance[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user