This commit is contained in:
Timo Knuth
2025-10-18 17:55:32 +02:00
parent 254e6490b8
commit 91b78cb284
65 changed files with 4481 additions and 1078 deletions

View File

@@ -0,0 +1,116 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
export default function CookieBanner() {
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
// Check if user has already made a choice
const cookieConsent = localStorage.getItem('cookieConsent');
if (!cookieConsent) {
// Show banner after a short delay for better UX
const timer = setTimeout(() => {
setShowBanner(true);
}, 1000);
return () => clearTimeout(timer);
}
}, []);
const handleAccept = () => {
localStorage.setItem('cookieConsent', 'accepted');
setShowBanner(false);
// Reload page to initialize PostHog
window.location.reload();
};
const handleDecline = () => {
localStorage.setItem('cookieConsent', 'declined');
setShowBanner(false);
};
if (!showBanner) return null;
return (
<>
{/* Cookie Banner - Bottom Left Corner */}
<div className="fixed bottom-4 left-4 z-50 max-w-md animate-slide-in">
<div className="bg-white rounded-lg shadow-2xl border border-gray-200 p-6">
<div className="flex items-start gap-3 mb-4">
<div className="flex-shrink-0">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div>
<h3 className="text-base font-semibold text-gray-900 mb-2">
We use cookies
</h3>
<p className="text-gray-600 text-sm leading-relaxed mb-3">
We use essential cookies for authentication and analytics cookies to improve your experience.{' '}
<Link href="/privacy" className="text-primary-600 hover:text-primary-700 font-medium underline">
Learn more
</Link>
</p>
{/* Cookie Categories */}
<div className="space-y-1.5 mb-4">
<div className="flex items-center text-xs">
<svg className="w-3.5 h-3.5 text-success-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-gray-700"><strong>Essential:</strong> Authentication, CSRF protection</span>
</div>
<div className="flex items-center text-xs">
<svg className="w-3.5 h-3.5 text-primary-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-gray-700"><strong>Analytics:</strong> PostHog for website analytics</span>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDecline}
size="sm"
className="flex-1"
>
Decline
</Button>
<Button
variant="primary"
onClick={handleAccept}
size="sm"
className="flex-1"
>
Accept All
</Button>
</div>
</div>
</div>
<style jsx>{`
@keyframes slide-in {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.4s ease-out;
}
`}</style>
</>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import posthog from 'posthog-js';
export function PostHogProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
// Check if user has consented to analytics cookies
const cookieConsent = localStorage.getItem('cookieConsent');
// Only initialize PostHog if user has accepted cookies
if (cookieConsent === 'accepted') {
posthog.init('phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ', {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only',
capture_pageview: false, // We'll capture manually
capture_pageleave: true,
autocapture: true,
// Privacy-friendly settings
respect_dnt: true,
opt_out_capturing_by_default: false,
loaded: (posthog) => {
if (process.env.NODE_ENV === 'development') {
posthog.debug();
}
},
});
}
// Cleanup on unmount
return () => {
if (cookieConsent === 'accepted') {
posthog.opt_out_capturing();
}
};
}, []);
// Track page views
useEffect(() => {
const cookieConsent = localStorage.getItem('cookieConsent');
if (cookieConsent === 'accepted' && pathname) {
let url = window.origin + pathname;
if (searchParams && searchParams.toString()) {
url = url + `?${searchParams.toString()}`;
}
posthog.capture('$pageview', {
$current_url: url,
});
}
}, [pathname, searchParams]);
return <>{children}</>;
}
/**
* Helper function to identify user after login
*/
export function identifyUser(userId: string, traits?: Record<string, any>) {
const cookieConsent = localStorage.getItem('cookieConsent');
if (cookieConsent === 'accepted') {
posthog.identify(userId, traits);
}
}
/**
* Helper function to track custom events
*/
export function trackEvent(eventName: string, properties?: Record<string, any>) {
const cookieConsent = localStorage.getItem('cookieConsent');
if (cookieConsent === 'accepted') {
posthog.capture(eventName, properties);
}
}
/**
* Helper function to reset user on logout
*/
export function resetUser() {
const cookieConsent = localStorage.getItem('cookieConsent');
if (cookieConsent === 'accepted') {
posthog.reset();
}
}

View File

@@ -18,9 +18,9 @@ interface QRCodeCardProps {
status: 'ACTIVE' | 'PAUSED';
createdAt: string;
scans?: number;
style?: any;
};
onEdit: (id: string) => void;
onDuplicate: (id: string) => void;
onPause: (id: string) => void;
onDelete: (id: string) => void;
}
@@ -28,7 +28,6 @@ interface QRCodeCardProps {
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr,
onEdit,
onDuplicate,
onPause,
onDelete,
}) => {
@@ -138,11 +137,14 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
>
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
<DropdownItem onClick={() => onDuplicate(qr.id)}>Duplicate</DropdownItem>
<DropdownItem onClick={() => onPause(qr.id)}>
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
</DropdownItem>
{qr.type === 'DYNAMIC' && (
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
)}
{qr.type === 'DYNAMIC' && (
<DropdownItem onClick={() => onPause(qr.id)}>
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
</DropdownItem>
)}
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
Delete
</DropdownItem>
@@ -153,8 +155,8 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
<QRCodeSVG
value={qrUrl}
size={96}
fgColor="#000000"
bgColor="#FFFFFF"
fgColor={qr.style?.foregroundColor || '#000000'}
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
level="M"
/>
</div>
@@ -164,10 +166,12 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
<span className="text-gray-500">Type:</span>
<span className="text-gray-900">{qr.contentType}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-500">Scans:</span>
<span className="text-gray-900">{qr.scans || 0}</span>
</div>
{qr.type === 'DYNAMIC' && (
<div className="flex items-center justify-between">
<span className="text-gray-500">Scans:</span>
<span className="text-gray-900">{qr.scans || 0}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-gray-500">Created:</span>
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>

View File

@@ -3,6 +3,7 @@
import React from 'react';
import { Card, CardContent } from '@/components/ui/Card';
import { formatNumber } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
interface StatsGridProps {
stats: {
@@ -13,12 +14,13 @@ interface StatsGridProps {
}
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
const { t } = useTranslation();
// Only show growth if there are actual scans
const showGrowth = stats.totalScans > 0;
const cards = [
{
title: 'Total Scans',
title: t('dashboard.stats.total_scans'),
value: formatNumber(stats.totalScans),
change: showGrowth ? '+12%' : 'No data yet',
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
@@ -30,7 +32,7 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
),
},
{
title: 'Active QR Codes',
title: t('dashboard.stats.active_codes'),
value: stats.activeQRCodes.toString(),
change: stats.activeQRCodes > 0 ? `${stats.activeQRCodes} active` : 'Create your first',
changeType: stats.activeQRCodes > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
@@ -41,7 +43,7 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
),
},
{
title: 'Conversion Rate',
title: t('dashboard.stats.conversion_rate'),
value: `${stats.conversionRate}%`,
change: stats.totalScans > 0 ? `${stats.conversionRate}% rate` : 'No scans yet',
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',

View File

@@ -15,15 +15,12 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
'static_vs_dynamic',
'forever',
'file_type',
'password',
'analytics',
'privacy',
'bulk',
];
return (
<section id="faq" className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.faq.title}
@@ -57,6 +54,12 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
</Card>
))}
</div>
<div className="text-center mt-8">
<a href="/faq" className="text-primary-600 hover:text-primary-700 font-medium">
View All Questions
</a>
</div>
</div>
</section>
);

View File

@@ -27,11 +27,20 @@ export const Features: React.FC<FeaturesProps> = ({ t }) => {
),
color: 'text-purple-600 bg-purple-100',
},
{
key: 'unlimited',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
color: 'text-green-600 bg-green-100',
},
];
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.features.title}

View File

@@ -12,15 +12,15 @@ interface HeroProps {
export const Hero: React.FC<HeroProps> = ({ t }) => {
const templateCards = [
{ title: 'Restaurant Menu', color: 'bg-pink-100', icon: '🍽️' },
{ title: 'Business Card', color: 'bg-blue-100', icon: '💼' },
{ title: 'Event Tickets', color: 'bg-green-100', icon: '🎫' },
{ title: 'WiFi Access', color: 'bg-purple-100', icon: '📶' },
{ title: 'URL/Website', color: 'bg-blue-100', icon: '🌐' },
{ title: 'WiFi', color: 'bg-purple-100', icon: '📶' },
{ title: 'Email', color: 'bg-green-100', icon: '📧' },
{ title: 'Phone Number', color: 'bg-pink-100', icon: '📞' },
];
return (
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left Content */}
<div className="space-y-8">
@@ -56,7 +56,7 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
{t.hero.cta_primary}
</Button>
</Link>
<Link href="/create">
<Link href="/#pricing">
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
{t.hero.cta_secondary}
</Button>

View File

@@ -28,22 +28,6 @@ export default function HomePageClient() {
return (
<>
<Hero t={t} />
<StatsStrip t={t} />
{/* Industry Buttons */}
<section className="py-8">
<div className="container mx-auto px-4">
<div className="flex flex-wrap justify-center gap-3">
{industries.map((industry) => (
<Button key={industry} variant="outline" size="sm">
{industry}
</Button>
))}
</div>
</div>
</section>
<TemplateCards t={t} />
<InstantGenerator t={t} />
<StaticVsDynamic t={t} />
<Features t={t} />

View File

@@ -74,7 +74,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.generator.title}
@@ -180,7 +180,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
</Button>
</div>
<Button className="w-full">
<Button className="w-full" onClick={() => window.location.href = '/login'}>
{t.generator.save_track}
</Button>
</Card>

View File

@@ -27,7 +27,7 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
return (
<section id="pricing" className="py-16">
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.pricing.title}
@@ -67,7 +67,7 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
<CardContent className="space-y-4">
<ul className="space-y-3">
{t.pricing[plan.key].features.map((feature: string, index: number) => (
{t.pricing[plan.key].features.slice(0, 3).map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3">
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
@@ -88,6 +88,12 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
</Card>
))}
</div>
<div className="text-center mt-8">
<a href="/#pricing" className="text-primary-600 hover:text-primary-700 font-medium">
View Full Pricing Details
</a>
</div>
</div>
</section>
);

View File

@@ -11,7 +11,7 @@ interface StaticVsDynamicProps {
export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
return (
<section className="py-16">
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
{/* Static QR Codes */}
<Card className="relative">

View File

@@ -16,7 +16,7 @@ export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => {
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<div key={stat.key} className="text-center">

View File

@@ -42,7 +42,7 @@ export const TemplateCards: React.FC<TemplateCardsProps> = ({ t }) => {
return (
<section className="py-16">
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.templates.title}

View File

@@ -0,0 +1,164 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { showToast } from '@/components/ui/Toast';
interface ChangePasswordModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function ChangePasswordModal({
isOpen,
onClose,
onSuccess,
}: ChangePasswordModalProps) {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
showToast('Please fill in all fields', 'error');
return;
}
if (newPassword.length < 8) {
showToast('New password must be at least 8 characters', 'error');
return;
}
if (newPassword !== confirmPassword) {
showToast('New passwords do not match', 'error');
return;
}
if (currentPassword === newPassword) {
showToast('New password must be different from current password', 'error');
return;
}
setLoading(true);
try {
const response = await fetch('/api/user/password', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword,
newPassword,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to change password');
}
showToast('Password changed successfully!', 'success');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
onSuccess();
} catch (error: any) {
showToast(error.message || 'Failed to change password', 'error');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
/>
{/* Modal */}
<div className="flex min-h-screen items-center justify-center p-4">
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900">Change Password</h2>
<p className="text-gray-600 mt-2">
Enter your current password and choose a new one
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Current Password
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Enter current password"
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Enter new password (min. 8 characters)"
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Confirm new password"
autoComplete="new-password"
/>
</div>
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
className="flex-1"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
className="flex-1"
disabled={loading}
>
{loading ? 'Updating...' : 'Update Password'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
}