4 neue dynamischen

This commit is contained in:
Timo Knuth
2026-01-11 01:29:21 +01:00
parent 268689f2ee
commit 05531cda3f
14 changed files with 1407 additions and 15 deletions

View File

@@ -242,6 +242,114 @@ export default function EditQRPage() {
</div>
)}
{qrCode.contentType === 'PDF' && (
<>
<Input
label="PDF/File URL"
value={content.fileUrl || ''}
onChange={(e) => setContent({ ...content, fileUrl: e.target.value })}
placeholder="https://drive.google.com/file/d/.../view"
required
/>
<Input
label="File Name (optional)"
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"

View 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>
);
}

View 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>
);
}