From 05531cda3f37a6174e0a9e83fcc2ce8e3a2eccc3 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Sun, 11 Jan 2026 01:29:21 +0100 Subject: [PATCH] 4 neue dynamischen --- prisma/schema.prisma | 4 + src/app/(app)/create/page.tsx | 206 ++++++++++++++++- src/app/(app)/qr/[id]/edit/page.tsx | 108 +++++++++ src/app/(app)/qr/[id]/feedback/page.tsx | 196 ++++++++++++++++ src/app/(app)/qr/[id]/page.tsx | 287 ++++++++++++++++++++++++ src/app/api/feedback/route.ts | 41 ++++ src/app/api/qrs/[id]/feedback/route.ts | 122 ++++++++++ src/app/api/qrs/public/[slug]/route.ts | 37 +++ src/app/api/qrs/route.ts | 12 + src/app/coupon/[slug]/page.tsx | 166 ++++++++++++++ src/app/feedback/[slug]/page.tsx | 195 ++++++++++++++++ src/app/r/[slug]/route.ts | 28 +++ src/components/dashboard/QRCodeCard.tsx | 10 + src/lib/validationSchemas.ts | 10 +- 14 files changed, 1407 insertions(+), 15 deletions(-) create mode 100644 src/app/(app)/qr/[id]/feedback/page.tsx create mode 100644 src/app/(app)/qr/[id]/page.tsx create mode 100644 src/app/api/feedback/route.ts create mode 100644 src/app/api/qrs/[id]/feedback/route.ts create mode 100644 src/app/api/qrs/public/[slug]/route.ts create mode 100644 src/app/coupon/[slug]/page.tsx create mode 100644 src/app/feedback/[slug]/page.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da00a83..9dd25a9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -112,6 +112,10 @@ enum ContentType { SMS TEXT WHATSAPP + PDF + APP + COUPON + FEEDBACK } enum QRStatus { diff --git a/src/app/(app)/create/page.tsx b/src/app/(app)/create/page.tsx index 8dc134b..4414184 100644 --- a/src/app/(app)/create/page.tsx +++ b/src/app/(app)/create/page.tsx @@ -14,6 +14,20 @@ import { calculateContrast, cn } from '@/lib/utils'; import { useTranslation } from '@/hooks/useTranslation'; import { useCsrf } from '@/hooks/useCsrf'; import { showToast } from '@/components/ui/Toast'; +import { + Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle +} from 'lucide-react'; + +// Tooltip component for form field help +const Tooltip = ({ text }: { text: string }) => ( +
+ +
+ {text} +
+
+
+); // Content-type specific frame options const getFrameOptionsForContentType = (contentType: string) => { @@ -34,6 +48,14 @@ const getFrameOptionsForContentType = (contentType: string) => { return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }]; case 'TEXT': return [...baseOptions, { id: 'read', label: 'Read' }, { id: 'info', label: 'Info' }]; + case 'PDF': + return [...baseOptions, { id: 'download', label: 'Download' }, { id: 'view', label: 'View PDF' }]; + case 'APP': + return [...baseOptions, { id: 'getapp', label: 'Get App' }, { id: 'download', label: 'Download' }]; + case 'COUPON': + return [...baseOptions, { id: 'redeem', label: 'Redeem' }, { id: 'save', label: 'Save Offer' }]; + case 'FEEDBACK': + return [...baseOptions, { id: 'review', label: 'Review' }, { id: 'feedback', label: 'Feedback' }]; default: return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }]; } @@ -102,10 +124,14 @@ export default function CreatePage() { const hasGoodContrast = contrast >= 4.5; const contentTypes = [ - { value: 'URL', label: 'URL / Website' }, - { value: 'VCARD', label: 'Contact Card' }, - { value: 'GEO', label: 'Location/Maps' }, - { value: 'PHONE', label: 'Phone Number' }, + { value: 'URL', label: 'URL / Website', icon: Globe }, + { value: 'VCARD', label: 'Contact Card', icon: User }, + { value: 'GEO', label: 'Location / Maps', icon: MapPin }, + { value: 'PHONE', label: 'Phone Number', icon: Phone }, + { value: 'PDF', label: 'PDF / File', icon: FileText }, + { value: 'APP', label: 'App Download', icon: Smartphone }, + { value: 'COUPON', label: 'Coupon / Discount', icon: Ticket }, + { value: 'FEEDBACK', label: 'Feedback / Review', icon: Star }, ]; // Get QR content based on content type @@ -128,6 +154,14 @@ export default function CreatePage() { return content.text || 'Sample text'; case 'WHATSAPP': return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`; + case 'PDF': + return content.fileUrl || 'https://example.com/file.pdf'; + case 'APP': + return content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com/app'; + case 'COUPON': + return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`; + case 'FEEDBACK': + return content.feedbackUrl || 'https://example.com/feedback'; default: return 'https://example.com'; } @@ -398,6 +432,139 @@ export default function CreatePage() { /> ); + case 'PDF': + return ( + <> +
+
+ + +
+ setContent({ ...content, fileUrl: e.target.value })} + placeholder="https://drive.google.com/file/d/.../view" + required + /> +
+ setContent({ ...content, fileName: e.target.value })} + placeholder="Product Catalog 2026" + /> + + ); + case 'APP': + return ( + <> +
+
+ + +
+ setContent({ ...content, iosUrl: e.target.value })} + placeholder="https://apps.apple.com/app/..." + /> +
+
+
+ + +
+ setContent({ ...content, androidUrl: e.target.value })} + placeholder="https://play.google.com/store/apps/..." + /> +
+
+
+ + +
+ setContent({ ...content, fallbackUrl: e.target.value })} + placeholder="https://yourapp.com" + /> +
+ + ); + case 'COUPON': + return ( + <> + setContent({ ...content, code: e.target.value })} + placeholder="SUMMER20" + required + /> + setContent({ ...content, discount: e.target.value })} + placeholder="20% OFF" + required + /> + setContent({ ...content, title: e.target.value })} + placeholder="Summer Sale 2026" + /> + setContent({ ...content, description: e.target.value })} + placeholder="Valid on all products" + /> + setContent({ ...content, expiryDate: e.target.value })} + /> + setContent({ ...content, redeemUrl: e.target.value })} + placeholder="https://shop.example.com?coupon=SUMMER20" + /> + + ); + case 'FEEDBACK': + return ( + <> + setContent({ ...content, businessName: e.target.value })} + placeholder="Your Restaurant Name" + required + /> +
+
+ + +
+ setContent({ ...content, googleReviewUrl: e.target.value })} + placeholder="https://search.google.com/local/writereview?placeid=..." + /> +
+ setContent({ ...content, thankYouMessage: e.target.value })} + placeholder="Thanks for your feedback!" + /> + + ); default: return null; } @@ -428,12 +595,31 @@ export default function CreatePage() { required /> - setContent({ ...content, fileUrl: e.target.value })} + placeholder="https://drive.google.com/file/d/.../view" + required + /> + setContent({ ...content, fileName: e.target.value })} + placeholder="Product Catalog 2026" + /> + + )} + + {qrCode.contentType === 'APP' && ( + <> + setContent({ ...content, iosUrl: e.target.value })} + placeholder="https://apps.apple.com/app/..." + /> + setContent({ ...content, androidUrl: e.target.value })} + placeholder="https://play.google.com/store/apps/..." + /> + setContent({ ...content, fallbackUrl: e.target.value })} + placeholder="https://yourapp.com" + /> + + )} + + {qrCode.contentType === 'COUPON' && ( + <> + setContent({ ...content, code: e.target.value })} + placeholder="SUMMER20" + required + /> + setContent({ ...content, discount: e.target.value })} + placeholder="20% OFF" + required + /> + setContent({ ...content, title: e.target.value })} + placeholder="Summer Sale 2026" + /> + setContent({ ...content, description: e.target.value })} + placeholder="Valid on all products" + /> + setContent({ ...content, expiryDate: e.target.value })} + /> + setContent({ ...content, redeemUrl: e.target.value })} + placeholder="https://shop.example.com" + /> + + )} + + {qrCode.contentType === 'FEEDBACK' && ( + <> + setContent({ ...content, businessName: e.target.value })} + placeholder="Your Restaurant Name" + required + /> + setContent({ ...content, googleReviewUrl: e.target.value })} + placeholder="https://search.google.com/local/writereview?placeid=..." + /> + setContent({ ...content, thankYouMessage: e.target.value })} + placeholder="Thanks for your feedback!" + /> + + )} +
+ + Page {currentPage} of {pagination.totalPages} + + +
+ )} + + + + ); +} diff --git a/src/app/(app)/qr/[id]/page.tsx b/src/app/(app)/qr/[id]/page.tsx new file mode 100644 index 0000000..e8ac40f --- /dev/null +++ b/src/app/(app)/qr/[id]/page.tsx @@ -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(null); + const [feedbackStats, setFeedbackStats] = useState(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) => ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ ); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!qrCode) return null; + + const qrUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/r/${qrCode.slug}`; + + return ( +
+ {/* Header */} +
+ + + Back to Dashboard + +
+
+

{qrCode.title}

+
+ + {qrCode.type} + + + {qrCode.status} + + {qrCode.contentType} +
+
+
+ {qrCode.type === 'DYNAMIC' && ( + <> + + + + + + )} +
+
+
+ +
+ {/* Left: QR Code */} +
+ + +
+ +
+ +
+ + + + +
+
+
+
+ + {/* Right: Stats & Info */} +
+ {/* Quick Stats */} +
+ + + +

{qrCode._count?.scans || 0}

+

Total Scans

+
+
+ + +

{qrCode.type}

+

QR Type

+
+
+ + +

+ {new Date(qrCode.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} +

+

Created

+
+
+
+ + {/* Feedback Summary (only for FEEDBACK type) */} + {qrCode.contentType === 'FEEDBACK' && ( + + + + + Customer Feedback + + + + {feedbackStats && feedbackStats.total > 0 ? ( +
+ {/* Average */} +
+
{feedbackStats.avgRating}
+ {renderStars(Math.round(feedbackStats.avgRating))} +

{feedbackStats.total} reviews

+
+ + {/* Distribution */} +
+ {[5, 4, 3, 2, 1].map((rating) => { + const count = feedbackStats.distribution[rating] || 0; + const pct = feedbackStats.total > 0 ? (count / feedbackStats.total) * 100 : 0; + return ( +
+ {rating}★ +
+
+
+ {count} +
+ ); + })} +
+
+ ) : ( +

No feedback received yet. Share your QR code to collect reviews!

+ )} + + + + + + + )} + + {/* Content Info */} + + + Content Details + + +
+                                {JSON.stringify(qrCode.content, null, 2)}
+                            
+
+
+
+
+
+ ); +} diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts new file mode 100644 index 0000000..53dcc50 --- /dev/null +++ b/src/app/api/feedback/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { slug, rating, comment } = body; + + if (!slug || !rating) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // Find the QR code + const qrCode = await db.qRCode.findUnique({ + where: { slug }, + select: { id: true }, + }); + + if (!qrCode) { + return NextResponse.json({ error: 'QR Code not found' }, { status: 404 }); + } + + // Log feedback as a scan with additional data + // In a full implementation, you'd have a Feedback model + // For now, we'll store it in QRScan with special markers + await db.qRScan.create({ + data: { + qrId: qrCode.id, + ipHash: 'feedback', + userAgent: `rating:${rating}|comment:${comment?.substring(0, 200) || ''}`, + device: 'feedback', + isUnique: true, + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error submitting feedback:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/qrs/[id]/feedback/route.ts b/src/app/api/qrs/[id]/feedback/route.ts new file mode 100644 index 0000000..2807e55 --- /dev/null +++ b/src/app/api/qrs/[id]/feedback/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { cookies } from 'next/headers'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + let userId: string | undefined; + + // Try NextAuth session first + const session = await getServerSession(authOptions); + if (session?.user?.id) { + userId = session.user.id; + } else { + // Fallback: Check raw userId cookie (like /api/user does) + const cookieStore = await cookies(); + userId = cookieStore.get('userId')?.value; + } + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '20'); + const skip = (page - 1) * limit; + + // Verify QR ownership and type + const qrCode = await db.qRCode.findUnique({ + where: { id, userId: userId }, + select: { id: true, contentType: true }, + }); + + if (!qrCode) { + return NextResponse.json({ error: 'QR code not found' }, { status: 404 }); + } + + // Check if consistent with schema (Prisma enum mismatch fix) + // @ts-ignore - Temporary ignore until client regeneration catches up fully in all envs + if (qrCode.contentType !== 'FEEDBACK') { + return NextResponse.json({ error: 'Not a feedback QR code' }, { status: 400 }); + } + + // Fetch feedback entries (stored as QRScans with ipHash='feedback') + const [feedbackEntries, totalCount] = await Promise.all([ + db.qRScan.findMany({ + where: { qrId: id, ipHash: 'feedback' }, + orderBy: { ts: 'desc' }, + skip, + take: limit, + select: { id: true, userAgent: true, ts: true }, + }), + db.qRScan.count({ + where: { qrId: id, ipHash: 'feedback' }, + }), + ]); + + // Parse feedback data from userAgent field + const feedbacks = feedbackEntries.map((entry) => { + const parsed = parseFeedback(entry.userAgent || ''); + return { + id: entry.id, + rating: parsed.rating, + comment: parsed.comment, + date: entry.ts, + }; + }); + + // Calculate stats + const allRatings = await db.qRScan.findMany({ + where: { qrId: id, ipHash: 'feedback' }, + select: { userAgent: true }, + }); + + const ratings = allRatings.map((e) => parseFeedback(e.userAgent || '').rating).filter((r) => r > 0); + const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0; + + // Rating distribution + const distribution = { + 5: ratings.filter((r) => r === 5).length, + 4: ratings.filter((r) => r === 4).length, + 3: ratings.filter((r) => r === 3).length, + 2: ratings.filter((r) => r === 2).length, + 1: ratings.filter((r) => r === 1).length, + }; + + return NextResponse.json({ + feedbacks, + stats: { + total: totalCount, + avgRating: Math.round(avgRating * 10) / 10, + distribution, + }, + pagination: { + page, + limit, + totalPages: Math.ceil(totalCount / limit), + hasMore: skip + limit < totalCount, + }, + }); + } catch (error) { + console.error('Error fetching feedback:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +function parseFeedback(userAgent: string): { rating: number; comment: string } { + // Format: "rating:4|comment:Great service!" + const ratingMatch = userAgent.match(/rating:(\d)/); + const commentMatch = userAgent.match(/comment:(.+)/); + + return { + rating: ratingMatch ? parseInt(ratingMatch[1]) : 0, + comment: commentMatch ? commentMatch[1] : '', + }; +} diff --git a/src/app/api/qrs/public/[slug]/route.ts b/src/app/api/qrs/public/[slug]/route.ts new file mode 100644 index 0000000..bf4d778 --- /dev/null +++ b/src/app/api/qrs/public/[slug]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + try { + const { slug } = await params; + + const qrCode = await db.qRCode.findUnique({ + where: { slug }, + select: { + id: true, + content: true, + contentType: true, + status: true, + }, + }); + + if (!qrCode) { + return NextResponse.json({ error: 'QR Code not found' }, { status: 404 }); + } + + if (qrCode.status === 'PAUSED') { + return NextResponse.json({ error: 'QR Code is paused' }, { status: 403 }); + } + + return NextResponse.json({ + contentType: qrCode.contentType, + content: qrCode.content, + }); + } catch (error) { + console.error('Error fetching public QR:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/qrs/route.ts b/src/app/api/qrs/route.ts index 0ef60ac..2295d23 100644 --- a/src/app/api/qrs/route.ts +++ b/src/app/api/qrs/route.ts @@ -182,6 +182,18 @@ END:VCARD`; case 'WHATSAPP': qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`; break; + case 'PDF': + qrContent = body.content.fileUrl || 'https://example.com/file.pdf'; + break; + case 'APP': + qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com'; + break; + case 'COUPON': + qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`; + break; + case 'FEEDBACK': + qrContent = body.content.feedbackUrl || 'https://example.com/feedback'; + break; default: qrContent = body.content.url || 'https://example.com'; } diff --git a/src/app/coupon/[slug]/page.tsx b/src/app/coupon/[slug]/page.tsx new file mode 100644 index 0000000..39194fd --- /dev/null +++ b/src/app/coupon/[slug]/page.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { Copy, Check, ExternalLink, Gift } from 'lucide-react'; + +interface CouponData { + code: string; + discount: string; + title?: string; + description?: string; + expiryDate?: string; + redeemUrl?: string; +} + +export default function CouponPage() { + const params = useParams(); + const slug = params.slug as string; + const [coupon, setCoupon] = useState(null); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + + useEffect(() => { + async function fetchCoupon() { + try { + const res = await fetch(`/api/qrs/public/${slug}`); + if (res.ok) { + const data = await res.json(); + if (data.contentType === 'COUPON') { + setCoupon(data.content as CouponData); + } + } + } catch (error) { + console.error('Error fetching coupon:', error); + } finally { + setLoading(false); + } + } + fetchCoupon(); + }, [slug]); + + const copyCode = async () => { + if (coupon?.code) { + await navigator.clipboard.writeText(coupon.code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + // Loading + if (loading) { + return ( +
+
+
+ ); + } + + // Not found + if (!coupon) { + return ( +
+
+

This coupon is not available.

+
+
+ ); + } + + const isExpired = coupon.expiryDate && new Date(coupon.expiryDate) < new Date(); + + return ( +
+
+ {/* Card */} +
+ {/* Colorful Header */} +
+ {/* Decorative circles */} +
+
+ +
+
+ +
+

{coupon.title || 'Special Offer'}

+

{coupon.discount}

+
+
+ + {/* Dotted line with circles */} +
+
+
+
+
+ + {/* Content */} +
+ {coupon.description && ( +

{coupon.description}

+ )} + + {/* Code Box */} +
+

Your Code

+
+ + {coupon.code} + + +
+ {copied && ( +

Copied!

+ )} +
+ + {/* Expiry */} + {coupon.expiryDate && ( +

+ {isExpired + ? '⚠️ This coupon has expired' + : `Valid until ${new Date(coupon.expiryDate).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + })}` + } +

+ )} + + {/* Redeem Button */} + {coupon.redeemUrl && !isExpired && ( + + + Redeem Now + + + + )} +
+
+ + {/* Footer */} +

+ Powered by QR Master +

+
+
+ ); +} diff --git a/src/app/feedback/[slug]/page.tsx b/src/app/feedback/[slug]/page.tsx new file mode 100644 index 0000000..098044c --- /dev/null +++ b/src/app/feedback/[slug]/page.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { Star, Send, Check } from 'lucide-react'; + +interface FeedbackData { + businessName: string; + googleReviewUrl?: string; + thankYouMessage?: string; +} + +export default function FeedbackPage() { + const params = useParams(); + const slug = params.slug as string; + const [feedback, setFeedback] = useState(null); + const [loading, setLoading] = useState(true); + const [rating, setRating] = useState(0); + const [hoverRating, setHoverRating] = useState(0); + const [comment, setComment] = useState(''); + const [submitted, setSubmitted] = useState(false); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + async function fetchFeedback() { + try { + const res = await fetch(`/api/qrs/public/${slug}`); + if (res.ok) { + const data = await res.json(); + if (data.contentType === 'FEEDBACK') { + setFeedback(data.content as FeedbackData); + } + } + } catch (error) { + console.error('Error fetching feedback data:', error); + } finally { + setLoading(false); + } + } + fetchFeedback(); + }, [slug]); + + const handleSubmit = async () => { + if (rating === 0) return; + + setSubmitting(true); + try { + await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug, rating, comment }), + }); + + setSubmitted(true); + + if (rating >= 4 && feedback?.googleReviewUrl) { + setTimeout(() => { + window.location.href = feedback.googleReviewUrl!; + }, 2000); + } + } catch (error) { + console.error('Error submitting feedback:', error); + } finally { + setSubmitting(false); + } + }; + + // Loading + if (loading) { + return ( +
+
+
+ ); + } + + // Not found + if (!feedback) { + return ( +
+
+

This feedback form is not available.

+
+
+ ); + } + + // Success + if (submitted) { + return ( +
+
+
+ +
+ +

+ Thank you! +

+ +

+ {feedback.thankYouMessage || 'Your feedback has been submitted.'} +

+ + {rating >= 4 && feedback.googleReviewUrl && ( +

+ Redirecting to Google Reviews... +

+ )} +
+
+ ); + } + + // Rating Form + return ( +
+
+ {/* Card */} +
+ {/* Colored Header */} +
+
+ +
+

How was your experience?

+

{feedback.businessName}

+
+ + {/* Content */} +
+ {/* Stars */} +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ + {/* Rating text */} +

0 ? '#6366f1' : 'transparent' }}> + {rating === 1 && 'Poor'} + {rating === 2 && 'Fair'} + {rating === 3 && 'Good'} + {rating === 4 && 'Great!'} + {rating === 5 && 'Excellent!'} +

+ + {/* Comment */} +
+