stripe
This commit is contained in:
parent
bccf771ffc
commit
cd3ee5fc8f
|
|
@ -2,7 +2,20 @@
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(docker-compose:*)",
|
"Bash(docker-compose:*)",
|
||||||
"Bash(docker container prune:*)"
|
"Bash(docker container prune:*)",
|
||||||
|
"Bash(npx prisma migrate dev:*)",
|
||||||
|
"Bash(npx prisma:*)",
|
||||||
|
"Bash(npm run dev)",
|
||||||
|
"Bash(timeout:*)",
|
||||||
|
"Bash(taskkill:*)",
|
||||||
|
"Bash(npx kill-port:*)",
|
||||||
|
"Bash(docker compose:*)",
|
||||||
|
"Bash(curl -I https://fonts.googleapis.com)",
|
||||||
|
"Bash(wsl:*)",
|
||||||
|
"Read(//c/Users/a931627/.ssh/**)",
|
||||||
|
"Bash(ssh-keygen:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(git remote add:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ services:
|
||||||
POSTGRES_DB: qrmaster
|
POSTGRES_DB: qrmaster
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8"
|
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8"
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5435:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/postgresql/data
|
- dbdata:/var/lib/postgresql/data
|
||||||
- ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
|
- ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -27,6 +27,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.0.12",
|
"@auth/prisma-adapter": "^1.0.12",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
|
"@stripe/stripe-js": "^8.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
|
@ -44,6 +45,7 @@
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-i18next": "^13.5.0",
|
"react-i18next": "^13.5.0",
|
||||||
"sharp": "^0.33.1",
|
"sharp": "^0.33.1",
|
||||||
|
"stripe": "^19.1.0",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
|
@ -16,6 +17,7 @@ export default function CreatePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
|
@ -33,6 +35,25 @@ export default function CreatePage() {
|
||||||
// QR preview
|
// QR preview
|
||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
|
||||||
|
// Check if user can customize colors (PRO+ only)
|
||||||
|
const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS';
|
||||||
|
|
||||||
|
// Load user plan
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserPlan = 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 fetching user plan:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchUserPlan();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const contrast = calculateContrast(foregroundColor, backgroundColor);
|
const contrast = calculateContrast(foregroundColor, backgroundColor);
|
||||||
const hasGoodContrast = contrast >= 4.5;
|
const hasGoodContrast = contrast >= 4.5;
|
||||||
|
|
||||||
|
|
@ -141,8 +162,9 @@ export default function CreatePage() {
|
||||||
isStatic: !isDynamic, // Add this flag
|
isStatic: !isDynamic, // Add this flag
|
||||||
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
|
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
style: {
|
style: {
|
||||||
foregroundColor,
|
// FREE users can only use black/white
|
||||||
backgroundColor,
|
foregroundColor: canCustomizeColors ? foregroundColor : '#000000',
|
||||||
|
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
|
||||||
cornerStyle,
|
cornerStyle,
|
||||||
size,
|
size,
|
||||||
},
|
},
|
||||||
|
|
@ -354,9 +376,26 @@ export default function CreatePage() {
|
||||||
{/* Style Section */}
|
{/* Style Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('create.style')}</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>{t('create.style')}</CardTitle>
|
||||||
|
{!canCustomizeColors && (
|
||||||
|
<Badge variant="warning">PRO Feature</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{!canCustomizeColors && (
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
<strong>Upgrade to PRO</strong> to customize colors, add logos, and brand your QR codes.
|
||||||
|
</p>
|
||||||
|
<Link href="/pricing">
|
||||||
|
<Button variant="primary" size="sm" className="mt-2">
|
||||||
|
Upgrade Now
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
|
@ -368,11 +407,13 @@ export default function CreatePage() {
|
||||||
value={foregroundColor}
|
value={foregroundColor}
|
||||||
onChange={(e) => setForegroundColor(e.target.value)}
|
onChange={(e) => setForegroundColor(e.target.value)}
|
||||||
className="w-12 h-10 rounded border border-gray-300"
|
className="w-12 h-10 rounded border border-gray-300"
|
||||||
|
disabled={!canCustomizeColors}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={foregroundColor}
|
value={foregroundColor}
|
||||||
onChange={(e) => setForegroundColor(e.target.value)}
|
onChange={(e) => setForegroundColor(e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
disabled={!canCustomizeColors}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -387,11 +428,13 @@ export default function CreatePage() {
|
||||||
value={backgroundColor}
|
value={backgroundColor}
|
||||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||||
className="w-12 h-10 rounded border border-gray-300"
|
className="w-12 h-10 rounded border border-gray-300"
|
||||||
|
disabled={!canCustomizeColors}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={backgroundColor}
|
value={backgroundColor}
|
||||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
disabled={!canCustomizeColors}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { StatsGrid } from '@/components/dashboard/StatsGrid';
|
import { StatsGrid } from '@/components/dashboard/StatsGrid';
|
||||||
import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
|
import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||||
|
|
||||||
interface QRCodeData {
|
interface QRCodeData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -24,10 +26,13 @@ interface QRCodeData {
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: session } = useSession();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [qrCodes, setQrCodes] = useState<QRCodeData[]>([]);
|
const [qrCodes, setQrCodes] = useState<QRCodeData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||||
|
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||||
|
const [upgradedPlan, setUpgradedPlan] = useState<string>('');
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalScans: 0,
|
totalScans: 0,
|
||||||
activeQRCodes: 0,
|
activeQRCodes: 0,
|
||||||
|
|
@ -118,6 +123,35 @@ export default function DashboardPage() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
// Load real QR codes and user plan from API
|
// Load real QR codes and user plan from API
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
|
@ -148,13 +182,11 @@ export default function DashboardPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch user plan
|
// Fetch user plan (using cookie-based auth, no session needed)
|
||||||
if (session?.user?.email) {
|
const userResponse = await fetch('/api/user/plan');
|
||||||
const userResponse = await fetch('/api/user/plan');
|
if (userResponse.ok) {
|
||||||
if (userResponse.ok) {
|
const userData = await userResponse.json();
|
||||||
const userData = await userResponse.json();
|
setUserPlan(userData.plan || 'FREE');
|
||||||
setUserPlan(userData.plan || 'FREE');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error);
|
console.error('Error fetching data:', error);
|
||||||
|
|
@ -170,7 +202,7 @@ export default function DashboardPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [session]);
|
}, []);
|
||||||
|
|
||||||
const handleEdit = (id: string) => {
|
const handleEdit = (id: string) => {
|
||||||
console.log('Edit QR:', id);
|
console.log('Edit QR:', id);
|
||||||
|
|
@ -200,16 +232,8 @@ export default function DashboardPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlanEmoji = (plan: string) => {
|
const getPlanEmoji = (plan: string) => {
|
||||||
switch (plan) {
|
// No emojis anymore
|
||||||
case 'FREE':
|
return '';
|
||||||
return '🟢';
|
|
||||||
case 'PRO':
|
|
||||||
return '🔵';
|
|
||||||
case 'BUSINESS':
|
|
||||||
return '🟣';
|
|
||||||
default:
|
|
||||||
return '⚪';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -222,7 +246,7 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Badge variant={getPlanBadgeColor(userPlan)} className="text-lg px-4 py-2">
|
<Badge variant={getPlanBadgeColor(userPlan)} className="text-lg px-4 py-2">
|
||||||
{getPlanEmoji(userPlan)} {userPlan} Plan
|
{userPlan} Plan
|
||||||
</Badge>
|
</Badge>
|
||||||
{userPlan === 'FREE' && (
|
{userPlan === 'FREE' && (
|
||||||
<Link href="/pricing">
|
<Link href="/pricing">
|
||||||
|
|
@ -302,6 +326,89 @@ export default function DashboardPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upgrade Success Dialog */}
|
||||||
|
<Dialog open={showUpgradeDialog} onOpenChange={setShowUpgradeDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl text-center">
|
||||||
|
Upgrade erfolgreich!
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-center text-base pt-4">
|
||||||
|
Willkommen im <strong>{upgradedPlan} Plan</strong>! Ihr Konto wurde erfolgreich aktualisiert.
|
||||||
|
</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">Ihre neuen 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 dynamische QR-Codes</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
<span>Branding (Logo, Farben anpassen)</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
<span>Detaillierte Analytics</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>Passwortschutz für QR-Codes</span>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{upgradedPlan === 'BUSINESS' && (
|
||||||
|
<>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
<span>500 dynamische QR-Codes</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
<span>Team-Zugänge (bis zu 3 User)</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
<span>Benutzerdefinierte Domains</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
<span>White-Label</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
<span>Prioritäts-Support</span>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowUpgradeDialog(false);
|
||||||
|
router.push('/create');
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Ersten QR-Code erstellen
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useSession } from 'next-auth/react';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
|
@ -10,15 +9,25 @@ import { showToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: session } = useSession();
|
const searchParams = useSearchParams();
|
||||||
|
const [user, setUser] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'yearly'>('monthly');
|
const [billingInterval, setBillingInterval] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
const [hasTriggeredCheckout, setHasTriggeredCheckout] = useState(false);
|
||||||
|
|
||||||
|
// Check for user in localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
if (storedUser) {
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const plans = [
|
const plans = [
|
||||||
{
|
{
|
||||||
id: 'FREE',
|
id: 'FREE',
|
||||||
name: 'Free / Starter',
|
name: 'Free / Starter',
|
||||||
icon: '🟢',
|
icon: '',
|
||||||
price: 0,
|
price: 0,
|
||||||
priceYearly: 0,
|
priceYearly: 0,
|
||||||
description: 'Privatnutzer & Testkunden',
|
description: 'Privatnutzer & Testkunden',
|
||||||
|
|
@ -36,7 +45,7 @@ export default function PricingPage() {
|
||||||
{
|
{
|
||||||
id: 'PRO',
|
id: 'PRO',
|
||||||
name: 'Pro',
|
name: 'Pro',
|
||||||
icon: '🔵',
|
icon: '',
|
||||||
price: 9,
|
price: 9,
|
||||||
priceYearly: 90,
|
priceYearly: 90,
|
||||||
description: 'Selbstständige / kleine Firmen',
|
description: 'Selbstständige / kleine Firmen',
|
||||||
|
|
@ -55,14 +64,13 @@ export default function PricingPage() {
|
||||||
{
|
{
|
||||||
id: 'BUSINESS',
|
id: 'BUSINESS',
|
||||||
name: 'Business',
|
name: 'Business',
|
||||||
icon: '🟣',
|
icon: '',
|
||||||
price: 29,
|
price: 29,
|
||||||
priceYearly: 290,
|
priceYearly: 290,
|
||||||
description: 'Agenturen / Startups',
|
description: 'Agenturen / Startups',
|
||||||
features: [
|
features: [
|
||||||
'500 QR-Codes',
|
'500 QR-Codes',
|
||||||
'Team-Zugänge (bis 3 User)',
|
'Team-Zugänge (bis 3 User)',
|
||||||
'API-Zugang',
|
|
||||||
'Benutzerdefinierte Domains',
|
'Benutzerdefinierte Domains',
|
||||||
'White-Label',
|
'White-Label',
|
||||||
'Prioritäts-Support',
|
'Prioritäts-Support',
|
||||||
|
|
@ -75,8 +83,24 @@ export default function PricingPage() {
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSubscribe = async (planId: string, priceId: string | null | undefined) => {
|
const handleSubscribe = async (planId: string, priceId: string | null | undefined) => {
|
||||||
if (!session) {
|
console.log('🔵 handleSubscribe called:', { planId, priceId, hasUser: !!user });
|
||||||
router.push('/login?redirect=/pricing');
|
|
||||||
|
if (!user) {
|
||||||
|
// Save the plan selection in localStorage so we can continue after login
|
||||||
|
const pendingPlan = {
|
||||||
|
planId,
|
||||||
|
interval: billingInterval,
|
||||||
|
};
|
||||||
|
console.log('💾 Saving pending plan to localStorage:', pendingPlan);
|
||||||
|
localStorage.setItem('pendingPlan', JSON.stringify(pendingPlan));
|
||||||
|
|
||||||
|
// Verify it was saved
|
||||||
|
const saved = localStorage.getItem('pendingPlan');
|
||||||
|
console.log('✅ Verified saved:', saved);
|
||||||
|
|
||||||
|
// Use window.location instead of router.push to ensure localStorage is written
|
||||||
|
console.log('🔄 Redirecting to login...');
|
||||||
|
window.location.href = '/login?redirect=/pricing';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,6 +123,7 @@ export default function PricingPage() {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
priceId,
|
priceId,
|
||||||
plan: planId,
|
plan: planId,
|
||||||
|
userEmail: user.email,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -117,6 +142,67 @@ export default function PricingPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-trigger checkout after login if plan is selected
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Pricing useEffect triggered:', {
|
||||||
|
hasUser: !!user,
|
||||||
|
hasTriggeredCheckout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only run once and only when authenticated
|
||||||
|
if (hasTriggeredCheckout) {
|
||||||
|
console.log('Already triggered checkout, skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Not authenticated - no user in localStorage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pending plan in localStorage
|
||||||
|
const pendingPlanStr = localStorage.getItem('pendingPlan');
|
||||||
|
if (pendingPlanStr) {
|
||||||
|
try {
|
||||||
|
const pendingPlan = JSON.parse(pendingPlanStr);
|
||||||
|
console.log('✅ Found pending plan:', pendingPlan);
|
||||||
|
|
||||||
|
// Clear pending plan immediately
|
||||||
|
localStorage.removeItem('pendingPlan');
|
||||||
|
|
||||||
|
// Mark as triggered to prevent re-runs
|
||||||
|
setHasTriggeredCheckout(true);
|
||||||
|
|
||||||
|
// Set the billing interval
|
||||||
|
setBillingInterval(pendingPlan.interval);
|
||||||
|
|
||||||
|
// Find the plan
|
||||||
|
const selectedPlan = plans.find((p) => p.id === pendingPlan.planId);
|
||||||
|
if (selectedPlan) {
|
||||||
|
const priceId =
|
||||||
|
pendingPlan.interval === 'yearly'
|
||||||
|
? selectedPlan.priceIdYearly
|
||||||
|
: selectedPlan.priceIdMonthly;
|
||||||
|
|
||||||
|
console.log('✅ Found plan and priceId:', selectedPlan.name, priceId);
|
||||||
|
|
||||||
|
// Trigger checkout after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('🚀 Calling handleSubscribe now...');
|
||||||
|
handleSubscribe(selectedPlan.id, priceId);
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
console.error('❌ Plan not found:', pendingPlan.planId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing pending plan:', e);
|
||||||
|
localStorage.removeItem('pendingPlan');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No pending plan in localStorage');
|
||||||
|
}
|
||||||
|
}, [user, hasTriggeredCheckout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
|
|
@ -180,7 +266,7 @@ export default function PricingPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardHeader className="text-center pb-6">
|
<CardHeader className="text-center pb-6">
|
||||||
<div className="text-4xl mb-4">{plan.icon}</div>
|
{plan.icon && <div className="text-4xl mb-4">{plan.icon}</div>}
|
||||||
<CardTitle className="text-2xl mb-2">{plan.name}</CardTitle>
|
<CardTitle className="text-2xl mb-2">{plan.name}</CardTitle>
|
||||||
<p className="text-sm text-gray-600">{plan.description}</p>
|
<p className="text-sm text-gray-600">{plan.description}</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
|
@ -10,6 +10,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
@ -33,7 +34,10 @@ export default function LoginPage() {
|
||||||
if (response.ok && data.success) {
|
if (response.ok && data.success) {
|
||||||
// Store user in localStorage for client-side
|
// Store user in localStorage for client-side
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
router.push('/dashboard');
|
|
||||||
|
// Check for redirect parameter
|
||||||
|
const redirectUrl = searchParams.get('redirect') || '/dashboard';
|
||||||
|
router.push(redirectUrl);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Invalid email or password');
|
setError(data.error || 'Invalid email or password');
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Pricing } from '@/components/marketing/Pricing';
|
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
|
||||||
|
|
||||||
export default function PricingPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="py-20">
|
|
||||||
<Pricing t={t} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -38,6 +38,13 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plan limits
|
||||||
|
const PLAN_LIMITS = {
|
||||||
|
FREE: 3,
|
||||||
|
PRO: 50,
|
||||||
|
BUSINESS: 500,
|
||||||
|
};
|
||||||
|
|
||||||
// POST /api/qrs - Create a new QR code
|
// POST /api/qrs - Create a new QR code
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -48,14 +55,15 @@ export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists
|
// Check if user exists and get their plan
|
||||||
const userExists = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { id: userId }
|
where: { id: userId },
|
||||||
|
select: { plan: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('User exists:', !!userExists);
|
console.log('User exists:', !!user);
|
||||||
|
|
||||||
if (!userExists) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +73,33 @@ export async function POST(request: NextRequest) {
|
||||||
// Check if this is a static QR request
|
// Check if this is a static QR request
|
||||||
const isStatic = body.isStatic === true;
|
const isStatic = body.isStatic === true;
|
||||||
|
|
||||||
|
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
||||||
|
if (!isStatic) {
|
||||||
|
// Count existing dynamic QR codes
|
||||||
|
const dynamicQRCount = await db.qRCode.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
type: 'DYNAMIC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userPlan = user.plan || 'FREE';
|
||||||
|
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
|
||||||
|
|
||||||
|
if (dynamicQRCount >= limit) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Limit reached',
|
||||||
|
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
|
||||||
|
currentCount: dynamicQRCount,
|
||||||
|
limit,
|
||||||
|
plan: userPlan,
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let enrichedContent = body.content;
|
let enrichedContent = body.content;
|
||||||
|
|
||||||
// For STATIC QR codes, calculate what the QR should contain
|
// For STATIC QR codes, calculate what the QR should contain
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
|
||||||
import { authOptions } from '@/lib/auth';
|
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
// Get user email from request body (since we're using simple auth, not NextAuth)
|
||||||
|
const { priceId, plan, userEmail } = await request.json();
|
||||||
|
|
||||||
if (!session?.user?.email) {
|
if (!userEmail) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized - No user email provided' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { priceId, plan } = await request.json();
|
|
||||||
|
|
||||||
if (!priceId || !plan) {
|
if (!priceId || !plan) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Missing priceId or plan' },
|
{ error: 'Missing priceId or plan' },
|
||||||
|
|
@ -23,7 +21,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
// Get user from database
|
// Get user from database
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { email: session.user.email },
|
where: { email: userEmail },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
@ -54,7 +52,7 @@ export async function POST(request: NextRequest) {
|
||||||
const checkoutSession = await stripe.checkout.sessions.create({
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
payment_method_types: ['card', 'sepa_debit'],
|
payment_method_types: ['card'],
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: priceId,
|
price: priceId,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { stripe } from '@/lib/stripe';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Use cookie-based auth instead of NextAuth
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.stripeCustomerId) {
|
||||||
|
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the most recent checkout session for this customer
|
||||||
|
const checkoutSessions = await stripe.checkout.sessions.list({
|
||||||
|
customer: user.stripeCustomerId,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkoutSessions.data.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkoutSession = checkoutSessions.data[0];
|
||||||
|
|
||||||
|
// Only process if payment was successful
|
||||||
|
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(
|
||||||
|
checkoutSession.subscription as string
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine plan from metadata or price ID
|
||||||
|
const plan = checkoutSession.metadata?.plan || 'PRO';
|
||||||
|
|
||||||
|
// Update user in database
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
stripeSubscriptionId: subscription.id,
|
||||||
|
stripePriceId: subscription.items.data[0].price.id,
|
||||||
|
stripeCurrentPeriodEnd: new Date(
|
||||||
|
subscription.current_period_end * 1000
|
||||||
|
),
|
||||||
|
plan: plan as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
plan,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying session:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { cookies } from 'next/headers';
|
||||||
import { authOptions } from '@/lib/auth';
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
// Use cookie-based auth instead of NextAuth
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
|
||||||
if (!session?.user?.email) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { email: session.user.email },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
plan: true,
|
plan: true,
|
||||||
stripeCurrentPeriodEnd: true,
|
stripeCurrentPeriodEnd: true,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import { ToastContainer } from '@/components/ui/Toast';
|
import { ToastContainer } from '@/components/ui/Toast';
|
||||||
|
import AuthProvider from '@/components/SessionProvider';
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'QR Master - Create Custom QR Codes in Seconds',
|
title: 'QR Master - Create Custom QR Codes in Seconds',
|
||||||
|
|
@ -33,8 +31,10 @@ export default function RootLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>
|
<body className="font-sans">
|
||||||
{children}
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
</AuthProvider>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SessionProvider } from 'next-auth/react';
|
||||||
|
|
||||||
|
export default function AuthProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue