SEO: Fix structured data validation errors, delete static sitemap, and update indexing scripts
This commit is contained in:
254
src/app/(main)/(app)/AppLayout.tsx
Normal file
254
src/app/(main)/(app)/AppLayout.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
|
||||
import { Footer } from '@/components/ui/Footer';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
plan: string | null;
|
||||
}
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
// Fetch user data on mount
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user');
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
setUser(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
// Track logout event before clearing data
|
||||
try {
|
||||
const { trackEvent, resetUser } = await import('@/components/PostHogProvider');
|
||||
trackEvent('user_logout');
|
||||
resetUser(); // Reset PostHog user session
|
||||
} catch (error) {
|
||||
console.error('PostHog tracking error:', error);
|
||||
}
|
||||
|
||||
// Clear all cookies
|
||||
document.cookie.split(";").forEach(c => {
|
||||
document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
|
||||
});
|
||||
// Clear localStorage
|
||||
localStorage.clear();
|
||||
// Redirect to home
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
// Get user initials for avatar (e.g., "Timo Schmidt" -> "TS")
|
||||
const getUserInitials = () => {
|
||||
if (!user) return 'U';
|
||||
|
||||
if (user.name) {
|
||||
const names = user.name.trim().split(' ');
|
||||
if (names.length >= 2) {
|
||||
return (names[0][0] + names[names.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return user.name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// Fallback to email
|
||||
return user.email.substring(0, 1).toUpperCase();
|
||||
};
|
||||
|
||||
// Get display name (first name or full name)
|
||||
const getDisplayName = () => {
|
||||
if (!user) return 'User';
|
||||
|
||||
if (user.name) {
|
||||
return user.name;
|
||||
}
|
||||
|
||||
// Fallback to email without domain
|
||||
return user.email.split('@')[0];
|
||||
};
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: t('nav.dashboard'),
|
||||
href: '/dashboard',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: t('nav.create_qr'),
|
||||
href: '/create',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: t('nav.bulk_creation'),
|
||||
href: '/bulk-creation',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: t('nav.analytics'),
|
||||
href: '/analytics',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: t('nav.pricing'),
|
||||
href: '/pricing',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: t('nav.settings'),
|
||||
href: '/settings',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform lg:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
|
||||
<span className="text-xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<button
|
||||
className="lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<svg className="w-6 h-6" 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>
|
||||
|
||||
<nav className="p-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-colors ${isActive
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:ml-64">
|
||||
{/* Top bar */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
className="lg:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
{/* User Menu */}
|
||||
<Dropdown
|
||||
align="right"
|
||||
trigger={
|
||||
<button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900">
|
||||
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-primary-600">
|
||||
{getUserInitials()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden md:block font-medium">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={handleSignOut}>
|
||||
Sign Out
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-6">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer variant="dashboard" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
594
src/app/(main)/(app)/analytics/page.tsx
Normal file
594
src/app/(main)/(app)/analytics/page.tsx
Normal file
@@ -0,0 +1,594 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { StatCard, Sparkline } from '@/components/analytics';
|
||||
import { Line, Doughnut } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import {
|
||||
BarChart3,
|
||||
Users,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Calendar,
|
||||
Download,
|
||||
TrendingUp,
|
||||
QrCode,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Dynamically import GeoMap to avoid SSR issues with d3
|
||||
const GeoMap = dynamic(() => import('@/components/analytics/GeoMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-64 bg-gray-100 rounded-lg animate-pulse flex items-center justify-center">
|
||||
<span className="text-gray-400">Loading map...</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface QRPerformance {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
totalScans: number;
|
||||
uniqueScans: number;
|
||||
conversion: number;
|
||||
trend: 'up' | 'down' | 'flat';
|
||||
trendPercentage: number;
|
||||
sparkline: number[];
|
||||
lastScanned: string | null;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
interface CountryStat {
|
||||
country: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
trend: 'up' | 'down' | 'flat';
|
||||
trendPercentage: number;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
interface AnalyticsData {
|
||||
summary: {
|
||||
totalScans: number;
|
||||
uniqueScans: number;
|
||||
avgScansPerQR: number;
|
||||
mobilePercentage: number;
|
||||
topCountry: string;
|
||||
topCountryPercentage: number;
|
||||
scansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean };
|
||||
avgScansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean };
|
||||
comparisonPeriod?: string;
|
||||
};
|
||||
deviceStats: Record<string, number>;
|
||||
countryStats: CountryStat[];
|
||||
dailyScans: Record<string, number>;
|
||||
qrPerformance: QRPerformance[];
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [timeRange, setTimeRange] = useState('7');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(null);
|
||||
|
||||
const fetchAnalytics = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analytics/summary?range=${timeRange}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAnalyticsData(data);
|
||||
} else {
|
||||
setAnalyticsData(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching analytics:', error);
|
||||
setAnalyticsData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
}, [fetchAnalytics]);
|
||||
|
||||
const exportReport = () => {
|
||||
if (!analyticsData) return;
|
||||
|
||||
const csvData = [
|
||||
['QR Master Analytics Report'],
|
||||
['Generated:', new Date().toLocaleString()],
|
||||
['Time Range:', `Last ${timeRange} days`],
|
||||
[''],
|
||||
['Summary'],
|
||||
['Total Scans', analyticsData.summary.totalScans],
|
||||
['Unique Scans', analyticsData.summary.uniqueScans],
|
||||
['Mobile Usage %', analyticsData.summary.mobilePercentage],
|
||||
['Top Country', analyticsData.summary.topCountry],
|
||||
[''],
|
||||
['Top QR Codes'],
|
||||
['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %', 'Last Scanned'],
|
||||
...analyticsData.qrPerformance.map((qr) => [
|
||||
qr.title,
|
||||
qr.type,
|
||||
qr.totalScans,
|
||||
qr.uniqueScans,
|
||||
qr.conversion,
|
||||
qr.lastScanned ? new Date(qr.lastScanned).toLocaleString() : 'Never',
|
||||
]),
|
||||
];
|
||||
|
||||
const csv = csvData.map((row) => row.join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `qr-analytics-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Prepare chart data
|
||||
const daysToShow = parseInt(timeRange);
|
||||
const dateRange = Array.from({ length: daysToShow }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (daysToShow - 1 - i));
|
||||
return date.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
const scanChartData = {
|
||||
labels: dateRange.map((date) => {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Scans',
|
||||
data: dateRange.map((date) => analyticsData?.dailyScans[date] || 0),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: (context: any) => {
|
||||
const chart = context.chart;
|
||||
const { ctx, chartArea } = chart;
|
||||
if (!chartArea) return 'rgba(59, 130, 246, 0.1)';
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
|
||||
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)');
|
||||
gradient.addColorStop(1, 'rgba(59, 130, 246, 0.01)');
|
||||
return gradient;
|
||||
},
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: 'rgb(59, 130, 246)',
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: 2,
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const deviceChartData = {
|
||||
labels: ['Desktop', 'Mobile', 'Tablet'],
|
||||
datasets: [
|
||||
{
|
||||
data: [
|
||||
analyticsData?.deviceStats.desktop || 0,
|
||||
analyticsData?.deviceStats.mobile || 0,
|
||||
analyticsData?.deviceStats.tablet || 0,
|
||||
],
|
||||
backgroundColor: [
|
||||
'rgba(59, 130, 246, 0.85)',
|
||||
'rgba(34, 197, 94, 0.85)',
|
||||
'rgba(249, 115, 22, 0.85)',
|
||||
],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Find top performing QR code
|
||||
const topQR = analyticsData?.qrPerformance[0];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8" />
|
||||
<div className="grid md:grid-cols-4 gap-6 mb-8">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<div className="h-80 bg-gray-200 rounded-xl" />
|
||||
<div className="h-80 bg-gray-200 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">QR Code Analytics</h1>
|
||||
<p className="text-gray-500 mt-1">Track and analyze your QR code performance</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Date Range Selector */}
|
||||
<div className="inline-flex items-center bg-gray-100 rounded-lg p-1">
|
||||
{[
|
||||
{ value: '7', label: '7 Days' },
|
||||
{ value: '30', label: '30 Days' },
|
||||
{ value: '90', label: '90 Days' },
|
||||
].map((range) => (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => setTimeRange(range.value)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-all ${timeRange === range.value
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button onClick={exportReport} variant="primary" className="flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Export Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Total Scans"
|
||||
value={analyticsData?.summary.totalScans || 0}
|
||||
trend={
|
||||
analyticsData?.summary.scansTrend
|
||||
? {
|
||||
direction: analyticsData.summary.scansTrend.trend,
|
||||
percentage: analyticsData.summary.scansTrend.percentage,
|
||||
isNew: analyticsData.summary.scansTrend.isNew,
|
||||
period: analyticsData.summary.comparisonPeriod,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
icon={<BarChart3 className="w-5 h-5 text-primary-600" />}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Avg Scans/QR"
|
||||
value={analyticsData?.summary.avgScansPerQR || 0}
|
||||
trend={
|
||||
analyticsData?.summary.avgScansTrend
|
||||
? {
|
||||
direction: analyticsData.summary.avgScansTrend.trend,
|
||||
percentage: analyticsData.summary.avgScansTrend.percentage,
|
||||
isNew: analyticsData.summary.avgScansTrend.isNew,
|
||||
period: analyticsData.summary.comparisonPeriod,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
icon={<TrendingUp className="w-5 h-5 text-primary-600" />}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Mobile Usage"
|
||||
value={`${analyticsData?.summary.mobilePercentage || 0}%`}
|
||||
subtitle="Of total scans"
|
||||
icon={<Smartphone className="w-5 h-5 text-primary-600" />}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Top Country"
|
||||
value={analyticsData?.summary.topCountry || 'N/A'}
|
||||
subtitle={`${analyticsData?.summary.topCountryPercentage || 0}% of total`}
|
||||
icon={<Globe className="w-5 h-5 text-primary-600" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Chart Row */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Scans Over Time - Takes 2 columns */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg font-semibold">Scan Trends Over Time</CardTitle>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{timeRange} Days</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72">
|
||||
<Line
|
||||
data={scanChartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17, 24, 39, 0.9)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
padding: 12,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (items) => items[0]?.label || '',
|
||||
label: (item) => `${item.formattedValue} scans`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#9CA3AF' },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(156, 163, 175, 0.1)' },
|
||||
ticks: { color: '#9CA3AF', precision: 0 },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device Types Donut */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">Device Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
{(analyticsData?.summary.totalScans || 0) > 0 ? (
|
||||
<Doughnut
|
||||
data={deviceChartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-400">No scan data available</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Geographic & Country Stats Row */}
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
{/* Geographic Insights with Map */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">Geographic Insights</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
<GeoMap
|
||||
countryStats={analyticsData?.countryStats || []}
|
||||
totalScans={analyticsData?.summary.totalScans || 0}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Countries Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">Top Countries</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(analyticsData?.countryStats?.length || 0) > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{analyticsData!.countryStats.slice(0, 5).map((country, index) => (
|
||||
<div
|
||||
key={country.country}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-semibold text-gray-400 w-6">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">{country.country}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-600">{country.count.toLocaleString()}</span>
|
||||
<span className="text-gray-400 text-sm w-12 text-right">
|
||||
{country.percentage}%
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
country.trend === 'up'
|
||||
? 'success'
|
||||
: country.trend === 'down'
|
||||
? 'error'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'}
|
||||
{country.trendPercentage}%{country.isNew ? ' (new)' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-8">No country data available yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Top Performing QR Codes with Sparklines */}
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
||||
<QrCode className="w-5 h-5" />
|
||||
Top Performing QR Codes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(analyticsData?.qrPerformance?.length || 0) > 0 ? (
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
<Table>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
|
||||
QR Code
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
|
||||
Type
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
|
||||
Total Scans
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
|
||||
Unique Scans
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Conversions</span>
|
||||
<div className="group relative inline-block">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 cursor-help" />
|
||||
<div className="invisible group-hover:visible absolute top-full left-1/2 -translate-x-1/2 mt-2 w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-xl z-[9999] pointer-events-none">
|
||||
<div className="font-semibold mb-1">Conversion Rate</div>
|
||||
<div className="text-gray-300">
|
||||
Percentage of unique scans vs total scans. Formula: (Unique Scans / Total Scans) × 100%
|
||||
</div>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-b-4 border-l-transparent border-r-transparent border-b-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
|
||||
Trend
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analyticsData!.qrPerformance.map((qr) => (
|
||||
<tr
|
||||
key={qr.id}
|
||||
className="border-b border-gray-50 transition-colors hover:bg-gray-50/50"
|
||||
>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
<span className="font-medium text-gray-900">{qr.title}</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
|
||||
{qr.type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-middle font-medium">
|
||||
{qr.totalScans.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-middle">{qr.uniqueScans.toLocaleString()}</td>
|
||||
<td className="px-4 py-4 align-middle">{qr.conversion}%</td>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkline
|
||||
data={qr.sparkline || [0, 0, 0, 0, 0, 0, 0]}
|
||||
color={
|
||||
qr.trend === 'up'
|
||||
? 'green'
|
||||
: qr.trend === 'down'
|
||||
? 'red'
|
||||
: 'blue'
|
||||
}
|
||||
/>
|
||||
<Badge
|
||||
variant={
|
||||
qr.trend === 'up'
|
||||
? 'success'
|
||||
: qr.trend === 'down'
|
||||
? 'error'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'}
|
||||
{qr.trendPercentage}%{qr.isNew ? ' (new)' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">
|
||||
No QR codes created yet. Create your first QR code to see analytics!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
719
src/app/(main)/(app)/bulk-creation/page.tsx
Normal file
719
src/app/(main)/(app)/bulk-creation/page.tsx
Normal file
@@ -0,0 +1,719 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import Papa from 'papaparse';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import JSZip from 'jszip';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
interface BulkQRData {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface GeneratedQR {
|
||||
title: string;
|
||||
content: string; // Original URL
|
||||
svg: string; // SVG markup
|
||||
}
|
||||
|
||||
export default function BulkCreationPage() {
|
||||
const { t } = useTranslation();
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [step, setStep] = useState<'upload' | 'preview' | 'complete'>('upload');
|
||||
const [data, setData] = useState<BulkQRData[]>([]);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]);
|
||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||
|
||||
// Check user plan on mount
|
||||
React.useEffect(() => {
|
||||
const checkPlan = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/plan');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserPlan(data.plan || 'FREE');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking plan:', error);
|
||||
}
|
||||
};
|
||||
checkPlan();
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
if (file.name.endsWith('.csv')) {
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const result = Papa.parse(text, { header: true });
|
||||
processData(result.data);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
|
||||
reader.onload = async (e) => {
|
||||
const buffer = e.target?.result as ArrayBuffer;
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const worksheet = workbook.worksheets[0];
|
||||
const jsonData: any[] = [];
|
||||
|
||||
// Get headers from first row
|
||||
const headers: string[] = [];
|
||||
const firstRow = worksheet.getRow(1);
|
||||
firstRow.eachCell((cell, colNumber) => {
|
||||
headers[colNumber - 1] = cell.value?.toString() || '';
|
||||
});
|
||||
|
||||
// Convert rows to objects
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return; // Skip header row
|
||||
const rowData: any = {};
|
||||
row.eachCell((cell, colNumber) => {
|
||||
const header = headers[colNumber - 1];
|
||||
if (header) {
|
||||
rowData[header] = cell.value;
|
||||
}
|
||||
});
|
||||
jsonData.push(rowData);
|
||||
});
|
||||
|
||||
processData(jsonData);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'text/csv': ['.csv'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
});
|
||||
|
||||
const processData = (rawData: any[]) => {
|
||||
// Limit to 1000 rows
|
||||
const limitedData = rawData.slice(0, 1000);
|
||||
|
||||
// Auto-detect columns
|
||||
if (limitedData.length > 0) {
|
||||
const columns = Object.keys(limitedData[0]);
|
||||
const autoMapping: Record<string, string> = {};
|
||||
|
||||
columns.forEach((col) => {
|
||||
const lowerCol = col.toLowerCase();
|
||||
if (lowerCol.includes('title') || lowerCol.includes('name') || lowerCol === 'test') {
|
||||
autoMapping.title = col;
|
||||
} else if (lowerCol.includes('content') || lowerCol.includes('url') || lowerCol.includes('data') || lowerCol.includes('link')) {
|
||||
autoMapping.content = col;
|
||||
}
|
||||
});
|
||||
|
||||
// If no title column found, use first column
|
||||
if (!autoMapping.title && columns.length > 0) {
|
||||
autoMapping.title = columns[0];
|
||||
}
|
||||
// If no content column found, use second column
|
||||
if (!autoMapping.content && columns.length > 1) {
|
||||
autoMapping.content = columns[1];
|
||||
}
|
||||
|
||||
setMapping(autoMapping);
|
||||
}
|
||||
|
||||
setData(limitedData);
|
||||
setStep('preview');
|
||||
};
|
||||
|
||||
const generateStaticQRCodes = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const qrCodes: GeneratedQR[] = [];
|
||||
|
||||
// Generate all QR codes client-side (Static QR Codes)
|
||||
for (const row of data) {
|
||||
const title = row[mapping.title as keyof typeof row] || 'Untitled';
|
||||
const content = row[mapping.content as keyof typeof row] || 'https://example.com';
|
||||
|
||||
// Create a temporary div to render QR code
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.display = 'none';
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '300');
|
||||
svg.setAttribute('height', '300');
|
||||
tempDiv.appendChild(svg);
|
||||
|
||||
// Use qrcode library to generate SVG
|
||||
const QRCode = require('qrcode');
|
||||
const qrSvg = await QRCode.toString(content, {
|
||||
type: 'svg',
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
qrCodes.push({
|
||||
title: String(title),
|
||||
content: String(content), // Store the original URL
|
||||
svg: qrSvg,
|
||||
});
|
||||
|
||||
document.body.removeChild(tempDiv);
|
||||
}
|
||||
|
||||
setGeneratedQRs(qrCodes);
|
||||
setStep('complete');
|
||||
showToast(`Successfully generated ${qrCodes.length} static QR codes!`, 'success');
|
||||
} catch (error) {
|
||||
console.error('QR generation error:', error);
|
||||
showToast('Failed to generate QR codes', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAllQRCodes = async () => {
|
||||
const zip = new JSZip();
|
||||
|
||||
generatedQRs.forEach((qr, index) => {
|
||||
const fileName = `${qr.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${index + 1}.svg`;
|
||||
zip.file(fileName, qr.svg);
|
||||
});
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
saveAs(blob, 'qr-codes-bulk.zip');
|
||||
showToast('Download started!', 'success');
|
||||
};
|
||||
|
||||
const saveQRCodesToDatabase = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const qrCodesToSave = generatedQRs.map((qr) => ({
|
||||
title: qr.title,
|
||||
isStatic: true, // This tells the API it's a static QR code
|
||||
contentType: 'URL',
|
||||
content: { url: qr.content }, // Content needs to be an object with url property
|
||||
status: 'ACTIVE',
|
||||
}));
|
||||
|
||||
// Save each QR code to the database
|
||||
const savePromises = qrCodesToSave.map((qr) =>
|
||||
fetchWithCsrf('/api/qrs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(qr),
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(savePromises);
|
||||
const failedCount = results.filter((r) => !r.ok).length;
|
||||
|
||||
if (failedCount === 0) {
|
||||
showToast(`Successfully saved ${qrCodesToSave.length} QR codes!`, 'success');
|
||||
// Redirect to dashboard after 1 second
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard';
|
||||
}, 1000);
|
||||
} else {
|
||||
showToast(`Saved ${qrCodesToSave.length - failedCount} QR codes, ${failedCount} failed`, 'warning');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving QR codes:', error);
|
||||
showToast('Failed to save QR codes', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const template = [
|
||||
{ title: 'Product Page', content: 'https://example.com/product' },
|
||||
{ title: 'Landing Page', content: 'https://example.com/landing' },
|
||||
{ title: 'Contact Form', content: 'https://example.com/contact' },
|
||||
{ title: 'About Us', content: 'https://example.com/about' },
|
||||
{ title: 'Pricing Page', content: 'https://example.com/pricing' },
|
||||
{ title: 'FAQ Page', content: 'https://example.com/faq' },
|
||||
{ title: 'Blog Article', content: 'https://example.com/blog/article-1' },
|
||||
{ title: 'Support Portal', content: 'https://example.com/support' },
|
||||
{ title: 'Download Page', content: 'https://example.com/download' },
|
||||
{ title: 'Social Media', content: 'https://instagram.com/yourcompany' },
|
||||
{ title: 'YouTube Video', content: 'https://youtube.com/watch?v=example' },
|
||||
];
|
||||
|
||||
const csv = Papa.unparse(template);
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'bulk-qr-template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Show upgrade prompt if not Business plan
|
||||
if (userPlan !== 'BUSINESS') {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card className="mt-12">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Business Plan Required</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
Bulk QR code creation is exclusively available for Business plan subscribers.
|
||||
Upgrade now to generate up to 1,000 static QR codes at once.
|
||||
</p>
|
||||
<div className="flex justify-center space-x-4">
|
||||
<Button variant="outline" onClick={() => window.location.href = '/dashboard'}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
<Button onClick={() => window.location.href = '/pricing'}>
|
||||
Upgrade to Business
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Template Warning Banner */}
|
||||
<Card className="mb-6 bg-warning-50 border-warning-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg className="w-6 h-6 text-warning-600 flex-shrink-0 mt-0.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>
|
||||
<div>
|
||||
<h3 className="font-semibold text-warning-900 mb-1">Please Follow the Template Format</h3>
|
||||
<p className="text-sm text-warning-800">
|
||||
Download the template below and follow the format exactly. Your CSV must include columns for <strong>title</strong> and <strong>content</strong> (URL).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Banner */}
|
||||
<Card className="mb-6 bg-blue-50 border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.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>
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-1">Static QR Codes Only</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
Bulk creation generates <strong>static QR codes</strong> that cannot be edited after creation.
|
||||
These QR codes do not include tracking or analytics. Perfect for print materials and offline use.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`flex items-center ${step === 'upload' ? 'text-primary-600' : 'text-gray-400'}`}>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${step === 'upload' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
||||
}`}>
|
||||
1
|
||||
</div>
|
||||
<span className="ml-3 font-medium">Upload File</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
|
||||
<div className={`h-full bg-primary-600 transition-all ${step === 'preview' || step === 'complete' ? 'w-full' : 'w-0'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${step === 'preview' || step === 'complete' ? 'text-primary-600' : 'text-gray-400'
|
||||
}`}>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${step === 'preview' || step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
||||
}`}>
|
||||
2
|
||||
</div>
|
||||
<span className="ml-3 font-medium">Preview & Map</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
|
||||
<div className={`h-full bg-primary-600 transition-all ${step === 'complete' ? 'w-full' : 'w-0'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${step === 'complete' ? 'text-primary-600' : 'text-gray-400'}`}>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
||||
}`}>
|
||||
3
|
||||
</div>
|
||||
<span className="ml-3 font-medium">Download</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Step */}
|
||||
{step === 'upload' && (
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<Button variant="outline" onClick={downloadTemplate}>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${isDragActive ? 'border-primary-500 bg-primary-50' : 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium text-gray-900 mb-2">
|
||||
{isDragActive ? 'Drop the file here' : 'Drag & drop your file here'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">or click to browse</p>
|
||||
<p className="text-xs text-gray-400">Supports CSV, XLS, XLSX (max 1,000 rows)</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Simple Format</p>
|
||||
<p className="text-sm text-gray-500">Just title & URL</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-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>
|
||||
<p className="font-medium text-gray-900">Static QR Codes</p>
|
||||
<p className="text-sm text-gray-500">No tracking included</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Instant Download</p>
|
||||
<p className="text-sm text-gray-500">Get ZIP with all SVGs</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Supported QR Code Types Section */}
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">📋 Supported QR Code Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-6">
|
||||
This bulk generator creates <strong>static QR codes</strong> for multiple content types. Choose the format that matches your needs:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">🌐 URL - Website Links</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">https://example.com</code></p>
|
||||
<p className="text-xs text-gray-500">Example: Product Page,https://example.com/product</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-purple-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">👤 VCARD - Contact Cards</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">FirstName,LastName,Email,Phone,Organization,Title</code></p>
|
||||
<p className="text-xs text-gray-500">Example: John Doe,"John,Doe,john@example.com,+1234567890,Company,CEO"</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-green-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">📍 GEO - Locations</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">latitude,longitude,label</code></p>
|
||||
<p className="text-xs text-gray-500">Example: Office Location,"37.7749,-122.4194,Main Office"</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-pink-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">📞 PHONE - Phone Numbers</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">+1234567890</code></p>
|
||||
<p className="text-xs text-gray-500">Example: Support Hotline,+1234567890</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-yellow-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">📝 TEXT - Plain Text</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">Any text content</code></p>
|
||||
<p className="text-xs text-gray-500">Example: Serial Number,SN-12345-ABCDE</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-6 mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">📥 CSV File Format:</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Your file needs <strong>two columns</strong>: <code className="bg-white px-2 py-1 rounded">title</code> and <code className="bg-white px-2 py-1 rounded">content</code>
|
||||
</p>
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-300">
|
||||
<th className="text-left py-2 px-3 font-semibold text-gray-700">title</th>
|
||||
<th className="text-left py-2 px-3 font-semibold text-gray-700">content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono text-xs">
|
||||
<tr className="border-b border-gray-200">
|
||||
<td className="py-2 px-3">Product Page</td>
|
||||
<td className="py-2 px-3">https://example.com/product</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200">
|
||||
<td className="py-2 px-3">John Doe</td>
|
||||
<td className="py-2 px-3">John,Doe,john@example.com,+1234567890,Company,CEO</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200">
|
||||
<td className="py-2 px-3">Office Location</td>
|
||||
<td className="py-2 px-3">37.7749,-122.4194,Main Office</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200">
|
||||
<td className="py-2 px-3">Support Hotline</td>
|
||||
<td className="py-2 px-3">+1234567890</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3">Serial Number</td>
|
||||
<td className="py-2 px-3">SN-12345-ABCDE</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-yellow-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">ℹ️ Important Notes</p>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• <strong>Static QR codes</strong> - Cannot be edited after creation</li>
|
||||
<li>• <strong>No tracking or analytics</strong> - Scans are not tracked</li>
|
||||
<li>• <strong>Maximum 1,000 QR codes</strong> per upload</li>
|
||||
<li>• <strong>Download as ZIP</strong> or save to your dashboard</li>
|
||||
<li>• <strong>All QR types supported</strong> - URLs, vCards, locations, phone numbers, and text</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-900">
|
||||
<strong>💡 Tip:</strong> Download the template above to see examples of all 5 QR code types with 11 ready-to-use examples!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Preview Step */}
|
||||
{step === 'preview' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Preview & Map Columns</CardTitle>
|
||||
<Badge variant="info">{data.length} rows detected</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-6 grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title Column</label>
|
||||
<Select
|
||||
value={mapping.title || ''}
|
||||
onChange={(e) => setMapping({ ...mapping, title: e.target.value })}
|
||||
options={Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Content/URL Column</label>
|
||||
<Select
|
||||
value={mapping.content || ''}
|
||||
onChange={(e) => setMapping({ ...mapping, content: e.target.value })}
|
||||
options={Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Preview</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Title</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.slice(0, 5).map((row: any, index) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="py-3 px-4">
|
||||
<QRCodeSVG
|
||||
value={row[mapping.content] || 'https://example.com'}
|
||||
size={40}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900">
|
||||
{row[mapping.title] || 'Untitled'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900">
|
||||
{(row[mapping.content] || '').substring(0, 50)}...
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data.length > 5 && (
|
||||
<p className="text-sm text-gray-500 mt-4 text-center">
|
||||
Showing 5 of {data.length} rows
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button variant="outline" onClick={() => setStep('upload')}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={generateStaticQRCodes} loading={loading}>
|
||||
Generate {data.length} Static QR Codes
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Complete Step */}
|
||||
{step === 'complete' && (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-20 h-20 bg-success-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-success-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Generation Complete!</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Successfully generated {generatedQRs.length} static QR codes
|
||||
</p>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
|
||||
{generatedQRs.slice(0, 8).map((qr, index) => (
|
||||
<div key={index} className="bg-white border border-gray-200 rounded-lg p-6 flex flex-col items-center shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="w-full flex items-center justify-center mb-4" style={{ height: '160px' }}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: qr.svg }}
|
||||
className="qr-code-container"
|
||||
style={{ maxWidth: '160px', maxHeight: '160px' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-900 font-medium text-center break-words w-full">{qr.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.qr-code-container :global(svg) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 160px !important;
|
||||
max-height: 160px !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="flex justify-center space-x-4">
|
||||
<Button variant="outline" onClick={() => {
|
||||
setStep('upload');
|
||||
setData([]);
|
||||
setMapping({});
|
||||
setGeneratedQRs([]);
|
||||
}}>
|
||||
Create More
|
||||
</Button>
|
||||
<Button variant="outline" onClick={downloadAllQRCodes}>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download All as ZIP
|
||||
</Button>
|
||||
<Button onClick={saveQRCodesToDatabase} loading={loading}>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
Save QR Codes
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1027
src/app/(main)/(app)/create/page.tsx
Normal file
1027
src/app/(main)/(app)/create/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
570
src/app/(main)/(app)/dashboard/page.tsx
Normal file
570
src/app/(main)/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,570 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { StatsGrid } from '@/components/dashboard/StatsGrid';
|
||||
import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||
|
||||
interface QRCodeData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'STATIC' | 'DYNAMIC';
|
||||
contentType: string;
|
||||
content?: any;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
scans: number;
|
||||
style?: any;
|
||||
status?: 'ACTIVE' | 'INACTIVE';
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [qrCodes, setQrCodes] = useState<QRCodeData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||
const [upgradedPlan, setUpgradedPlan] = useState<string>('');
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [stats, setStats] = useState({
|
||||
totalScans: 0,
|
||||
activeQRCodes: 0,
|
||||
conversionRate: 0,
|
||||
uniqueScans: 0,
|
||||
});
|
||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||
|
||||
const mockQRCodes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Support Phone',
|
||||
type: 'DYNAMIC' as const,
|
||||
contentType: 'PHONE',
|
||||
slug: 'support-phone-demo',
|
||||
status: 'ACTIVE' as const,
|
||||
createdAt: '2025-08-07T10:00:00Z',
|
||||
scans: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Event Details',
|
||||
type: 'DYNAMIC' as const,
|
||||
contentType: 'URL',
|
||||
slug: 'event-details-demo',
|
||||
status: 'ACTIVE' as const,
|
||||
createdAt: '2025-08-07T10:01:00Z',
|
||||
scans: 0,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Product Demo',
|
||||
type: 'DYNAMIC' as const,
|
||||
contentType: 'URL',
|
||||
slug: 'product-demo-qr',
|
||||
status: 'ACTIVE' as const,
|
||||
createdAt: '2025-08-07T10:02:00Z',
|
||||
scans: 0,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Company Website',
|
||||
type: 'DYNAMIC' as const,
|
||||
contentType: 'URL',
|
||||
slug: 'company-website-qr',
|
||||
status: 'ACTIVE' as const,
|
||||
createdAt: '2025-08-07T10:03:00Z',
|
||||
scans: 0,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Contact Card',
|
||||
type: 'DYNAMIC' as const,
|
||||
contentType: 'VCARD',
|
||||
slug: 'contact-card-qr',
|
||||
status: 'ACTIVE' as const,
|
||||
createdAt: '2025-08-07T10:04:00Z',
|
||||
scans: 0,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Event Details',
|
||||
type: 'DYNAMIC' as const,
|
||||
contentType: 'URL',
|
||||
slug: 'event-details-dup',
|
||||
status: 'ACTIVE' as const,
|
||||
createdAt: '2025-08-07T10:05:00Z',
|
||||
scans: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const blogPosts = [
|
||||
// NEW POSTS
|
||||
{
|
||||
title: 'How to Create a QR Code for Restaurant Menu',
|
||||
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Best practices for touchless menus.',
|
||||
readTime: '12 Min',
|
||||
slug: 'qr-code-restaurant-menu',
|
||||
},
|
||||
{
|
||||
title: 'Free vCard QR Code Generator: Digital Business Cards',
|
||||
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly.',
|
||||
readTime: '10 Min',
|
||||
slug: 'vcard-qr-code-generator',
|
||||
},
|
||||
{
|
||||
title: 'Best QR Code Generator for Small Business',
|
||||
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases.',
|
||||
readTime: '14 Min',
|
||||
slug: 'qr-code-small-business',
|
||||
},
|
||||
{
|
||||
title: 'QR Code Print Size Guide',
|
||||
excerpt: 'Complete guide to QR code print sizes. Minimum dimensions for business cards, posters, and more.',
|
||||
readTime: '8 Min',
|
||||
slug: 'qr-code-print-size-guide',
|
||||
},
|
||||
// EXISTING POSTS
|
||||
{
|
||||
title: 'QR Code Tracking: Complete Guide 2025',
|
||||
excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools.',
|
||||
readTime: '12 Min',
|
||||
slug: 'qr-code-tracking-guide-2025',
|
||||
},
|
||||
{
|
||||
title: 'Dynamic vs Static QR Codes: Which Should You Use?',
|
||||
excerpt: 'Understand the difference between static and dynamic QR codes. Pros, cons, and when to use each.',
|
||||
readTime: '10 Min',
|
||||
slug: 'dynamic-vs-static-qr-codes',
|
||||
},
|
||||
{
|
||||
title: 'How to Generate Bulk QR Codes from Excel',
|
||||
excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide.',
|
||||
readTime: '13 Min',
|
||||
slug: 'bulk-qr-code-generator-excel',
|
||||
},
|
||||
{
|
||||
title: 'QR Code Analytics: Track, Measure & Optimize',
|
||||
excerpt: 'Learn how to leverage scan analytics and dashboard insights to maximize QR code ROI.',
|
||||
readTime: '15 Min',
|
||||
slug: 'qr-code-analytics',
|
||||
},
|
||||
];
|
||||
|
||||
// Track Google OAuth login/signup
|
||||
useEffect(() => {
|
||||
const authMethod = searchParams.get('authMethod');
|
||||
const isNewUser = searchParams.get('isNewUser') === 'true';
|
||||
|
||||
if (authMethod === 'google') {
|
||||
const trackGoogleAuth = async () => {
|
||||
try {
|
||||
// Fetch user data from API (cookie-based auth)
|
||||
const response = await fetch('/api/user');
|
||||
if (!response.ok) return;
|
||||
|
||||
const user = await response.json();
|
||||
|
||||
// Store in localStorage for consistency
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
|
||||
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
||||
identifyUser(user.id, {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan || 'FREE',
|
||||
provider: 'google',
|
||||
});
|
||||
|
||||
trackEvent(isNewUser ? 'user_signup' : 'user_login', {
|
||||
method: 'google',
|
||||
email: user.email,
|
||||
isNewUser,
|
||||
});
|
||||
|
||||
// Clean up URL params
|
||||
router.replace('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('PostHog tracking error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
trackGoogleAuth();
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
// Check for successful payment and verify session
|
||||
useEffect(() => {
|
||||
const success = searchParams.get('success');
|
||||
if (success === 'true') {
|
||||
const verifySession = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stripe/verify-session', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserPlan(data.plan);
|
||||
setUpgradedPlan(data.plan);
|
||||
setShowUpgradeDialog(true);
|
||||
// Remove success parameter from URL
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
console.error('Failed to verify session:', await response.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
verifySession();
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load real QR codes and user plan from API
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch QR codes
|
||||
const qrResponse = await fetch('/api/qrs');
|
||||
if (qrResponse.ok) {
|
||||
const data = await qrResponse.json();
|
||||
setQrCodes(data);
|
||||
|
||||
// Calculate real stats
|
||||
const totalScans = data.reduce((sum: number, qr: QRCodeData) => sum + (qr.scans || 0), 0);
|
||||
const activeQRCodes = data.filter((qr: QRCodeData) => qr.status === 'ACTIVE').length;
|
||||
// Calculate unique scans (absolute count)
|
||||
const uniqueScans = data.reduce((acc: number, qr: any) => acc + (qr.uniqueScans || 0), 0);
|
||||
const conversionRate = totalScans > 0 ? Math.round((uniqueScans / totalScans) * 100) : 0;
|
||||
|
||||
setStats({
|
||||
totalScans,
|
||||
activeQRCodes,
|
||||
conversionRate,
|
||||
uniqueScans,
|
||||
});
|
||||
} else {
|
||||
// If not logged in, show zeros
|
||||
setQrCodes([]);
|
||||
setStats({
|
||||
totalScans: 0,
|
||||
activeQRCodes: 0,
|
||||
conversionRate: 0,
|
||||
uniqueScans: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch user plan (using cookie-based auth, no session needed)
|
||||
const userResponse = await fetch('/api/user/plan');
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
setUserPlan(userData.plan || 'FREE');
|
||||
}
|
||||
|
||||
// Fetch analytics data for trends (last 30 days = month comparison)
|
||||
const analyticsResponse = await fetch('/api/analytics/summary?range=30');
|
||||
if (analyticsResponse.ok) {
|
||||
const analytics = await analyticsResponse.json();
|
||||
setAnalyticsData(analytics);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
setQrCodes([]);
|
||||
setStats({
|
||||
totalScans: 0,
|
||||
activeQRCodes: 0,
|
||||
conversionRate: 0,
|
||||
uniqueScans: 0,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
// Redirect to edit page
|
||||
router.push(`/qr/${id}/edit`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this QR code? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf(`/api/qrs/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Remove from local state
|
||||
setQrCodes(qrCodes.filter(q => q.id !== id));
|
||||
showToast('QR code deleted successfully!', 'success');
|
||||
} else {
|
||||
throw new Error('Failed to delete');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting QR:', error);
|
||||
showToast('Failed to delete QR code', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
if (!confirm('Are you sure you want to delete ALL QR codes? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Double confirmation
|
||||
if (!confirm('This will permanently delete ALL your QR codes. Are you absolutely sure?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingAll(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/qrs/delete-all', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setQrCodes([]);
|
||||
setStats({
|
||||
totalScans: 0,
|
||||
activeQRCodes: 0,
|
||||
conversionRate: 0,
|
||||
uniqueScans: 0,
|
||||
});
|
||||
showToast(`Successfully deleted ${data.deletedCount} QR code${data.deletedCount !== 1 ? 's' : ''}`, 'success');
|
||||
} else {
|
||||
throw new Error('Failed to delete all QR codes');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting all QR codes:', error);
|
||||
showToast('Failed to delete QR codes', 'error');
|
||||
} finally {
|
||||
setDeletingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanBadgeColor = (plan: string) => {
|
||||
switch (plan) {
|
||||
case 'PRO':
|
||||
return 'info';
|
||||
case 'BUSINESS':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanEmoji = (plan: string) => {
|
||||
// No emojis anymore
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header with Plan Badge */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">{t('dashboard.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge variant={getPlanBadgeColor(userPlan)} className="text-lg px-4 py-2">
|
||||
{userPlan} Plan
|
||||
</Badge>
|
||||
{userPlan === 'FREE' && (
|
||||
<Link href="/pricing">
|
||||
<Button variant="primary">Upgrade</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
trends={{
|
||||
totalScans: analyticsData?.summary.scansTrend,
|
||||
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Recent QR Codes */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('dashboard.recent_codes')}</h2>
|
||||
<div className="flex gap-3">
|
||||
{qrCodes.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDeleteAll}
|
||||
disabled={deletingAll}
|
||||
className="border-red-600 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
{deletingAll ? 'Deleting...' : 'Delete All'}
|
||||
</Button>
|
||||
)}
|
||||
<Link href="/create">
|
||||
<Button>Create New QR Code</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-3"></div>
|
||||
<div className="h-24 bg-gray-200 rounded mb-3"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-gray-200 rounded"></div>
|
||||
<div className="h-3 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{qrCodes.map((qr) => (
|
||||
<QRCodeCard
|
||||
key={qr.id}
|
||||
qr={qr}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blog & Resources - Horizontal Scroll */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">{t('dashboard.blog_resources')}</h2>
|
||||
<div className="overflow-x-auto pb-4 -mx-4 px-4">
|
||||
<div className="flex gap-6" style={{ minWidth: 'max-content' }}>
|
||||
{blogPosts.map((post) => (
|
||||
<Card key={post.slug} hover className="flex-shrink-0" style={{ width: '300px' }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Badge variant="info">{post.readTime}</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-lg line-clamp-2">{post.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 text-sm line-clamp-2">{post.excerpt}</p>
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="text-primary-600 hover:text-primary-700 text-sm font-medium mt-3 inline-block"
|
||||
>
|
||||
Read more →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Success Dialog */}
|
||||
<Dialog open={showUpgradeDialog} onOpenChange={setShowUpgradeDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-center">
|
||||
Upgrade Successful!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center text-base pt-4">
|
||||
Welcome to the <strong>{upgradedPlan} Plan</strong>! Your account has been successfully upgraded.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-6 space-y-4">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-200">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Your New Features:</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-700">
|
||||
{upgradedPlan === 'PRO' && (
|
||||
<>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>50 Dynamic QR Codes</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Custom Branding (Colors & Logo)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Detailed Analytics (Devices, Locations, Time-Series)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>CSV Export</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>SVG/PNG Download</span>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{upgradedPlan === 'BUSINESS' && (
|
||||
<>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>500 Dynamic QR Codes</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Everything from Pro</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Bulk QR Generation (up to 1,000)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Priority Support</span>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setShowUpgradeDialog(false);
|
||||
router.push('/create');
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Create First QR Code
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
392
src/app/(main)/(app)/integrations/page.tsx
Normal file
392
src/app/(main)/(app)/integrations/page.tsx
Normal file
@@ -0,0 +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>
|
||||
);
|
||||
}
|
||||
32
src/app/(main)/(app)/layout.tsx
Normal file
32
src/app/(main)/(app)/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from 'next';
|
||||
import '@/styles/globals.css';
|
||||
import { Suspense } from 'react';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import AppLayout from './AppLayout';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Dashboard | QR Master',
|
||||
description: 'Manage your QR Master dashboard. Create dynamic QR codes, view real-time scan analytics, and configure your account settings in one secure place.',
|
||||
robots: { index: false, follow: false },
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ url: '/logo.svg', type: 'image/svg+xml' },
|
||||
],
|
||||
apple: '/logo.svg',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AppGroupLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AppLayout>
|
||||
{children}
|
||||
</AppLayout>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
459
src/app/(main)/(app)/qr/[id]/edit/page.tsx
Normal file
459
src/app/(main)/(app)/qr/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { Upload, FileText, HelpCircle } from 'lucide-react';
|
||||
|
||||
// Tooltip component for form field help
|
||||
const Tooltip = ({ text }: { text: string }) => (
|
||||
<div className="group relative inline-block ml-1">
|
||||
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
|
||||
{text}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function EditQRPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const qrId = params.id as string;
|
||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [qrCode, setQrCode] = useState<any>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQRCode = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/qrs/${qrId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setQrCode(data);
|
||||
setTitle(data.title);
|
||||
setContent(data.content || {});
|
||||
} else {
|
||||
showToast('Failed to load QR code', 'error');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
showToast('Failed to load QR code', 'error');
|
||||
router.push('/dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQRCode();
|
||||
}, [qrId, router]);
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 10MB limit
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
showToast('File size too large (max 10MB)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setContent({ ...content, fileUrl: data.url, fileName: data.filename });
|
||||
showToast('File uploaded successfully!', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Upload failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
showToast('Error uploading file', 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('QR code updated successfully!', 'success');
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.error || 'Failed to update QR code', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating QR code:', error);
|
||||
showToast('Failed to update QR code', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading QR code...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!qrCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Static QR codes cannot be edited
|
||||
if (qrCode.type === 'STATIC') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto mt-12">
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-warning-600" 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>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Static QR codes cannot be edited because their content is embedded directly in the QR code image.
|
||||
</p>
|
||||
<Button onClick={() => router.push('/dashboard')}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
|
||||
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>QR Code Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Input
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter QR code title"
|
||||
required
|
||||
/>
|
||||
|
||||
{qrCode.contentType === 'URL' && (
|
||||
<Input
|
||||
label="URL"
|
||||
type="url"
|
||||
value={content.url || ''}
|
||||
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'PHONE' && (
|
||||
<Input
|
||||
label="Phone Number"
|
||||
type="tel"
|
||||
value={content.phone || ''}
|
||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||
placeholder="+1234567890"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'VCARD' && (
|
||||
<>
|
||||
<Input
|
||||
label="First Name"
|
||||
value={content.firstName || ''}
|
||||
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
value={content.lastName || ''}
|
||||
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={content.email || ''}
|
||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
value={content.phone || ''}
|
||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||
placeholder="+1234567890"
|
||||
/>
|
||||
<Input
|
||||
label="Organization"
|
||||
value={content.organization || ''}
|
||||
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
||||
placeholder="Company Name"
|
||||
/>
|
||||
<Input
|
||||
label="Job Title"
|
||||
value={content.title || ''}
|
||||
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||
placeholder="CEO"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'GEO' && (
|
||||
<>
|
||||
<Input
|
||||
label="Latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={content.latitude || ''}
|
||||
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="37.7749"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={content.longitude || ''}
|
||||
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="-122.4194"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Location Label (Optional)"
|
||||
value={content.label || ''}
|
||||
onChange={(e) => setContent({ ...content, label: e.target.value })}
|
||||
placeholder="Golden Gate Bridge"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'TEXT' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Text Content
|
||||
</label>
|
||||
<textarea
|
||||
value={content.text || ''}
|
||||
onChange={(e) => setContent({ ...content, text: 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"
|
||||
rows={4}
|
||||
placeholder="Enter your text content"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'PDF' && (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-center mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
|
||||
<Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
|
||||
<div className="space-y-1 text-center">
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
|
||||
<p className="text-sm text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : content.fileUrl ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
|
||||
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
|
||||
{content.fileName || 'View File'}
|
||||
</a>
|
||||
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||
<span>Replace File</span>
|
||||
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="flex text-sm text-gray-600 justify-center">
|
||||
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
|
||||
<span>Upload a file</span>
|
||||
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||
</label>
|
||||
<p className="pl-1">or drag and drop</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{content.fileUrl && (
|
||||
<Input
|
||||
label="File Name / Menu Title"
|
||||
value={content.fileName || ''}
|
||||
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
|
||||
placeholder="Product Catalog 2026"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'APP' && (
|
||||
<>
|
||||
<Input
|
||||
label="iOS App Store URL"
|
||||
value={content.iosUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
|
||||
placeholder="https://apps.apple.com/app/..."
|
||||
/>
|
||||
<Input
|
||||
label="Android Play Store URL"
|
||||
value={content.androidUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
|
||||
placeholder="https://play.google.com/store/apps/..."
|
||||
/>
|
||||
<Input
|
||||
label="Fallback URL (Desktop)"
|
||||
value={content.fallbackUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
|
||||
placeholder="https://yourapp.com"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'COUPON' && (
|
||||
<>
|
||||
<Input
|
||||
label="Coupon Code"
|
||||
value={content.code || ''}
|
||||
onChange={(e) => setContent({ ...content, code: e.target.value })}
|
||||
placeholder="SUMMER20"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Discount"
|
||||
value={content.discount || ''}
|
||||
onChange={(e) => setContent({ ...content, discount: e.target.value })}
|
||||
placeholder="20% OFF"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Title"
|
||||
value={content.title || ''}
|
||||
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||
placeholder="Summer Sale 2026"
|
||||
/>
|
||||
<Input
|
||||
label="Description (optional)"
|
||||
value={content.description || ''}
|
||||
onChange={(e) => setContent({ ...content, description: e.target.value })}
|
||||
placeholder="Valid on all products"
|
||||
/>
|
||||
<Input
|
||||
label="Expiry Date (optional)"
|
||||
type="date"
|
||||
value={content.expiryDate || ''}
|
||||
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Redeem URL (optional)"
|
||||
value={content.redeemUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
|
||||
placeholder="https://shop.example.com"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'FEEDBACK' && (
|
||||
<>
|
||||
<Input
|
||||
label="Business Name"
|
||||
value={content.businessName || ''}
|
||||
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
|
||||
placeholder="Your Restaurant Name"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Google Review URL (optional)"
|
||||
value={content.googleReviewUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
|
||||
placeholder="https://search.google.com/local/writereview?placeid=..."
|
||||
/>
|
||||
<Input
|
||||
label="Thank You Message"
|
||||
value={content.thankYouMessage || ''}
|
||||
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
|
||||
placeholder="Thanks for your feedback!"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-4 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={csrfLoading || saving}
|
||||
>
|
||||
{csrfLoading ? 'Loading...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
src/app/(main)/(app)/qr/[id]/feedback/page.tsx
Normal file
196
src/app/(main)/(app)/qr/[id]/feedback/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Star, ArrowLeft, ChevronLeft, ChevronRight, MessageSquare } from 'lucide-react';
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface FeedbackStats {
|
||||
total: number;
|
||||
avgRating: number;
|
||||
distribution: { [key: number]: number };
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export default function FeedbackListPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const qrId = params.id as string;
|
||||
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [stats, setStats] = useState<FeedbackStats | null>(null);
|
||||
const [pagination, setPagination] = useState<Pagination>({ page: 1, totalPages: 1, hasMore: false });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback(currentPage);
|
||||
}, [qrId, currentPage]);
|
||||
|
||||
const fetchFeedback = async (page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/qrs/${qrId}/feedback?page=${page}&limit=20`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFeedbacks(data.feedbacks);
|
||||
setStats(data.stats);
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => (
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link href={`/qr/${qrId}`} className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to QR Code
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Customer Feedback</h1>
|
||||
<p className="text-gray-600 mt-1">{stats?.total || 0} total responses</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<Card className="mb-8">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-8">
|
||||
{/* Average Rating */}
|
||||
<div className="text-center md:text-left">
|
||||
<div className="text-5xl font-bold text-gray-900 mb-1">{stats.avgRating}</div>
|
||||
<div className="flex justify-center md:justify-start mb-1">
|
||||
{renderStars(Math.round(stats.avgRating))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{stats.total} reviews</p>
|
||||
</div>
|
||||
|
||||
{/* Distribution */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{[5, 4, 3, 2, 1].map((rating) => {
|
||||
const count = stats.distribution[rating] || 0;
|
||||
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
|
||||
return (
|
||||
<div key={rating} className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 w-12">{rating} stars</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-400 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 w-12 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Feedback List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
All Reviews
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{feedbacks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Star className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No feedback received yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{feedbacks.map((feedback) => (
|
||||
<div key={feedback.id} className="py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{renderStars(feedback.rating)}
|
||||
<span className="text-sm text-gray-400">
|
||||
{new Date(feedback.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{feedback.comment && (
|
||||
<p className="text-gray-700">{feedback.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Page {currentPage} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => p + 1)}
|
||||
disabled={!pagination.hasMore}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
src/app/(main)/(app)/qr/[id]/page.tsx
Normal file
287
src/app/(main)/(app)/qr/[id]/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
ArrowLeft, Edit, ExternalLink, Star, MessageSquare,
|
||||
BarChart3, Copy, Check, Pause, Play
|
||||
} from 'lucide-react';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
interface QRCode {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'STATIC' | 'DYNAMIC';
|
||||
contentType: string;
|
||||
content: any;
|
||||
slug: string;
|
||||
status: 'ACTIVE' | 'PAUSED';
|
||||
style: any;
|
||||
createdAt: string;
|
||||
_count?: { scans: number };
|
||||
}
|
||||
|
||||
interface FeedbackStats {
|
||||
total: number;
|
||||
avgRating: number;
|
||||
distribution: { [key: number]: number };
|
||||
}
|
||||
|
||||
export default function QRDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const qrId = params.id as string;
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
|
||||
const [qrCode, setQrCode] = useState<QRCode | null>(null);
|
||||
const [feedbackStats, setFeedbackStats] = useState<FeedbackStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQRCode();
|
||||
}, [qrId]);
|
||||
|
||||
const fetchQRCode = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/qrs/${qrId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQrCode(data);
|
||||
|
||||
// Fetch feedback stats if it's a feedback QR
|
||||
if (data.contentType === 'FEEDBACK') {
|
||||
const feedbackRes = await fetch(`/api/qrs/${qrId}/feedback?limit=1`);
|
||||
if (feedbackRes.ok) {
|
||||
const feedbackData = await feedbackRes.json();
|
||||
setFeedbackStats(feedbackData.stats);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showToast('QR code not found', 'error');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
const url = `${window.location.origin}/r/${qrCode?.slug}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
showToast('Link copied!', 'success');
|
||||
};
|
||||
|
||||
const toggleStatus = async () => {
|
||||
if (!qrCode) return;
|
||||
const newStatus = qrCode.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
|
||||
|
||||
try {
|
||||
const res = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setQrCode({ ...qrCode, status: newStatus });
|
||||
showToast(`QR code ${newStatus === 'ACTIVE' ? 'activated' : 'paused'}`, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to update status', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => (
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!qrCode) return null;
|
||||
|
||||
const qrUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/r/${qrCode.slug}`;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link href="/dashboard" className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{qrCode.title}</h1>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant={qrCode.type === 'DYNAMIC' ? 'info' : 'default'}>
|
||||
{qrCode.type}
|
||||
</Badge>
|
||||
<Badge variant={qrCode.status === 'ACTIVE' ? 'success' : 'warning'}>
|
||||
{qrCode.status}
|
||||
</Badge>
|
||||
<Badge>{qrCode.contentType}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{qrCode.type === 'DYNAMIC' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={toggleStatus}>
|
||||
{qrCode.status === 'ACTIVE' ? <Pause className="w-4 h-4 mr-1" /> : <Play className="w-4 h-4 mr-1" />}
|
||||
{qrCode.status === 'ACTIVE' ? 'Pause' : 'Activate'}
|
||||
</Button>
|
||||
<Link href={`/qr/${qrId}/edit`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="w-4 h-4 mr-1" /> Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Left: QR Code */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent className="p-6 flex flex-col items-center">
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm mb-4">
|
||||
<QRCodeSVG
|
||||
value={qrUrl}
|
||||
size={200}
|
||||
fgColor={qrCode.style?.foregroundColor || '#000000'}
|
||||
bgColor={qrCode.style?.backgroundColor || '#FFFFFF'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<Button variant="outline" className="w-full" onClick={copyLink}>
|
||||
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
|
||||
{copied ? 'Copied!' : 'Copy Link'}
|
||||
</Button>
|
||||
<a href={qrUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ExternalLink className="w-4 h-4 mr-2" /> Open Link
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Stats & Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<BarChart3 className="w-6 h-6 mx-auto mb-2 text-indigo-500" />
|
||||
<p className="text-2xl font-bold text-gray-900">{qrCode._count?.scans || 0}</p>
|
||||
<p className="text-sm text-gray-500">Total Scans</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{qrCode.type}</p>
|
||||
<p className="text-sm text-gray-500">QR Type</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{new Date(qrCode.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Created</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Feedback Summary (only for FEEDBACK type) */}
|
||||
{qrCode.contentType === 'FEEDBACK' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-amber-400" />
|
||||
Customer Feedback
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{feedbackStats && feedbackStats.total > 0 ? (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-6 mb-4">
|
||||
{/* Average */}
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-4xl font-bold text-gray-900">{feedbackStats.avgRating}</div>
|
||||
{renderStars(Math.round(feedbackStats.avgRating))}
|
||||
<p className="text-sm text-gray-500 mt-1">{feedbackStats.total} reviews</p>
|
||||
</div>
|
||||
|
||||
{/* Distribution */}
|
||||
<div className="flex-1 space-y-1">
|
||||
{[5, 4, 3, 2, 1].map((rating) => {
|
||||
const count = feedbackStats.distribution[rating] || 0;
|
||||
const pct = feedbackStats.total > 0 ? (count / feedbackStats.total) * 100 : 0;
|
||||
return (
|
||||
<div key={rating} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-8 text-gray-500">{rating}★</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-amber-400 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="w-8 text-gray-400 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 mb-4">No feedback received yet. Share your QR code to collect reviews!</p>
|
||||
)}
|
||||
|
||||
<Link href={`/qr/${qrId}/feedback`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
View All Feedback
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Content Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-gray-50 p-4 rounded-lg text-sm overflow-auto">
|
||||
{JSON.stringify(qrCode.content, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
386
src/app/(main)/(app)/settings/page.tsx
Normal file
386
src/app/(main)/(app)/settings/page.tsx
Normal file
@@ -0,0 +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>
|
||||
);
|
||||
}
|
||||
158
src/app/(main)/(app)/test/page.tsx
Normal file
158
src/app/(main)/(app)/test/page.tsx
Normal file
@@ -0,0 +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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user