Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
05531cda3f |
|
|
@ -112,6 +112,10 @@ enum ContentType {
|
|||
SMS
|
||||
TEXT
|
||||
WHATSAPP
|
||||
PDF
|
||||
APP
|
||||
COUPON
|
||||
FEEDBACK
|
||||
}
|
||||
|
||||
enum QRStatus {
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<div className="group relative inline-block ml-1">
|
||||
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
|
||||
{text}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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() {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
case 'PDF':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-center mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">PDF/File URL</label>
|
||||
<Tooltip text="Paste a public link to your PDF (Google Drive, Dropbox, etc.)" />
|
||||
</div>
|
||||
<Input
|
||||
value={content.fileUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, fileUrl: e.target.value })}
|
||||
placeholder="https://drive.google.com/file/d/.../view"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="File Name (optional)"
|
||||
value={content.fileName || ''}
|
||||
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
|
||||
placeholder="Product Catalog 2026"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'APP':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-center mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">iOS App Store URL</label>
|
||||
<Tooltip text="Link to your app in the Apple App Store" />
|
||||
</div>
|
||||
<Input
|
||||
value={content.iosUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
|
||||
placeholder="https://apps.apple.com/app/..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Android Play Store URL</label>
|
||||
<Tooltip text="Link to your app in the Google Play Store" />
|
||||
</div>
|
||||
<Input
|
||||
value={content.androidUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
|
||||
placeholder="https://play.google.com/store/apps/..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Fallback URL</label>
|
||||
<Tooltip text="Where desktop users go (e.g., your website). QR detects device automatically!" />
|
||||
</div>
|
||||
<Input
|
||||
value={content.fallbackUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
|
||||
placeholder="https://yourapp.com"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'COUPON':
|
||||
return (
|
||||
<>
|
||||
<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?coupon=SUMMER20"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'FEEDBACK':
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
label="Business Name"
|
||||
value={content.businessName || ''}
|
||||
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
|
||||
placeholder="Your Restaurant Name"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Google Review URL</label>
|
||||
<Tooltip text="Redirect satisfied customers to leave a Google review." />
|
||||
</div>
|
||||
<Input
|
||||
value={content.googleReviewUrl || ''}
|
||||
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
|
||||
placeholder="https://search.google.com/local/writereview?placeid=..."
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Thank You Message"
|
||||
value={content.thankYouMessage || ''}
|
||||
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
|
||||
placeholder="Thanks for your feedback!"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -428,12 +595,31 @@ export default function CreatePage() {
|
|||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Content Type"
|
||||
value={contentType}
|
||||
onChange={(e) => setContentType(e.target.value)}
|
||||
options={contentTypes}
|
||||
/>
|
||||
{/* Custom Content Type Selector with Icons */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Content Type</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{contentTypes.map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setContentType(type.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all text-sm",
|
||||
contentType === type.value
|
||||
? "border-primary-500 bg-primary-50 text-primary-700"
|
||||
: "border-gray-200 hover:border-gray-300 text-gray-600"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-xs font-medium text-center">{type.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderContentFields()}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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] : '',
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CouponData | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100">
|
||||
<div className="w-10 h-10 border-3 border-pink-200 border-t-pink-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not found
|
||||
if (!coupon) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100 px-6">
|
||||
<div className="text-center bg-white rounded-2xl p-8 shadow-lg">
|
||||
<p className="text-gray-500 text-lg">This coupon is not available.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isExpired = coupon.expiryDate && new Date(coupon.expiryDate) < new Date();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 via-pink-50 to-orange-50 px-6 py-12">
|
||||
<div className="max-w-sm w-full">
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||
{/* Colorful Header */}
|
||||
<div className="bg-gradient-to-r from-rose-500 to-orange-500 text-white p-8 text-center relative overflow-hidden">
|
||||
{/* Decorative circles */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div className="absolute bottom-0 left-0 w-24 h-24 bg-white/10 rounded-full translate-y-1/2 -translate-x-1/2"></div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Gift className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<p className="text-white/80 text-sm mb-1">{coupon.title || 'Special Offer'}</p>
|
||||
<p className="text-4xl font-bold tracking-tight">{coupon.discount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dotted line with circles */}
|
||||
<div className="relative py-4">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-rose-50 via-pink-50 to-orange-50 rounded-r-full"></div>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-rose-50 via-pink-50 to-orange-50 rounded-l-full"></div>
|
||||
<div className="border-t-2 border-dashed border-gray-200 mx-8"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-8 pb-8">
|
||||
{coupon.description && (
|
||||
<p className="text-gray-500 text-sm text-center mb-6">{coupon.description}</p>
|
||||
)}
|
||||
|
||||
{/* Code Box */}
|
||||
<div className="bg-gradient-to-r from-rose-50 to-orange-50 rounded-2xl p-5 mb-4 border border-rose-100">
|
||||
<p className="text-xs text-rose-400 text-center mb-2 font-medium uppercase tracking-wider">Your Code</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<code className="text-2xl font-mono font-bold text-gray-900 tracking-wider">
|
||||
{coupon.code}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyCode}
|
||||
className={`p-2.5 rounded-xl transition-all ${copied
|
||||
? 'bg-emerald-100 text-emerald-600'
|
||||
: 'bg-white text-gray-500 hover:text-rose-500 shadow-sm hover:shadow'
|
||||
}`}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-emerald-600 text-xs text-center mt-2 font-medium">Copied!</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expiry */}
|
||||
{coupon.expiryDate && (
|
||||
<p className={`text-sm text-center mb-6 font-medium ${isExpired ? 'text-red-500' : 'text-gray-400'}`}>
|
||||
{isExpired
|
||||
? '⚠️ This coupon has expired'
|
||||
: `Valid until ${new Date(coupon.expiryDate).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Redeem Button */}
|
||||
{coupon.redeemUrl && !isExpired && (
|
||||
<a
|
||||
href={coupon.redeemUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full py-4 rounded-xl font-semibold text-center bg-gradient-to-r from-rose-500 to-orange-500 text-white hover:from-rose-600 hover:to-orange-600 transition-all shadow-lg shadow-rose-200"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
Redeem Now
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-rose-300 mt-6">
|
||||
Powered by QR Master
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<FeedbackData | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<div className="w-10 h-10 border-3 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not found
|
||||
if (!feedback) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-6">
|
||||
<div className="text-center bg-white rounded-2xl p-8 shadow-lg">
|
||||
<p className="text-gray-500 text-lg">This feedback form is not available.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-100 px-6">
|
||||
<div className="max-w-sm w-full bg-white rounded-3xl shadow-xl p-10 text-center">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-emerald-400 to-teal-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||
<Check className="w-10 h-10 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Thank you!
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-500">
|
||||
{feedback.thankYouMessage || 'Your feedback has been submitted.'}
|
||||
</p>
|
||||
|
||||
{rating >= 4 && feedback.googleReviewUrl && (
|
||||
<p className="text-sm text-teal-600 mt-6 font-medium">
|
||||
Redirecting to Google Reviews...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Rating Form
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 px-6 py-12">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||
{/* Colored Header */}
|
||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 text-white p-8 text-center">
|
||||
<div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Star className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-1">How was your experience?</h1>
|
||||
<p className="text-white/80">{feedback.businessName}</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
{/* Stars */}
|
||||
<div className="flex justify-center gap-2 mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
className="p-1 transition-transform hover:scale-110 focus:outline-none"
|
||||
aria-label={`Rate ${star} stars`}
|
||||
>
|
||||
<Star
|
||||
className={`w-11 h-11 transition-all ${star <= (hoverRating || rating)
|
||||
? 'text-amber-400 fill-amber-400 drop-shadow-sm'
|
||||
: 'text-gray-200'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rating text */}
|
||||
<p className="text-center text-sm font-medium h-6 mb-6" style={{ color: rating > 0 ? '#6366f1' : 'transparent' }}>
|
||||
{rating === 1 && 'Poor'}
|
||||
{rating === 2 && 'Fair'}
|
||||
{rating === 3 && 'Good'}
|
||||
{rating === 4 && 'Great!'}
|
||||
{rating === 5 && 'Excellent!'}
|
||||
</p>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="mb-6">
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Share your thoughts (optional)"
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={rating === 0 || submitting}
|
||||
className={`w-full py-4 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all ${rating === 0
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-indigo-500 to-purple-600 text-white hover:from-indigo-600 hover:to-purple-700 shadow-lg shadow-indigo-200'
|
||||
}`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{submitting ? 'Sending...' : 'Submit Feedback'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-indigo-300 mt-6">
|
||||
Powered by QR Master
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,6 +59,34 @@ export async function GET(
|
|||
const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
|
||||
break;
|
||||
case 'PDF':
|
||||
// Direct link to file
|
||||
destination = content.fileUrl || 'https://example.com/file.pdf';
|
||||
break;
|
||||
case 'APP':
|
||||
// Smart device detection for app stores
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const isIOS = /iphone|ipad|ipod/i.test(userAgent);
|
||||
const isAndroid = /android/i.test(userAgent);
|
||||
|
||||
if (isIOS && content.iosUrl) {
|
||||
destination = content.iosUrl;
|
||||
} else if (isAndroid && content.androidUrl) {
|
||||
destination = content.androidUrl;
|
||||
} else {
|
||||
destination = content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com';
|
||||
}
|
||||
break;
|
||||
case 'COUPON':
|
||||
// Redirect to coupon display page
|
||||
const baseUrlCoupon = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||
destination = `${baseUrlCoupon}/coupon/${slug}`;
|
||||
break;
|
||||
case 'FEEDBACK':
|
||||
// Redirect to feedback form page
|
||||
const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||
destination = `${baseUrlFeedback}/feedback/${slug}`;
|
||||
break;
|
||||
default:
|
||||
destination = 'https://example.com';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ END:VCARD`;
|
|||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => window.location.href = `/qr/${qr.id}`}>View Details</DropdownItem>
|
||||
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
|
||||
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
|
||||
{qr.type === 'DYNAMIC' && (
|
||||
|
|
@ -246,6 +247,15 @@ END:VCARD`;
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Feedback Button - only for FEEDBACK type */}
|
||||
{qr.contentType === 'FEEDBACK' && (
|
||||
<button
|
||||
onClick={() => window.location.href = `/qr/${qr.id}/feedback`}
|
||||
className="w-full mt-3 py-2 px-3 bg-amber-50 hover:bg-amber-100 text-amber-700 rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
⭐ View Customer Feedback
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const createQRSchema = z.object({
|
|||
|
||||
isStatic: z.boolean().optional(),
|
||||
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT'], {
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK'], {
|
||||
errorMap: () => ({ message: 'Invalid content type' })
|
||||
}),
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ export const bulkQRSchema = z.object({
|
|||
z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
content: z.string().min(1).max(5000),
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT']),
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK']),
|
||||
})
|
||||
).min(1, 'At least one QR code is required')
|
||||
.max(100, 'Maximum 100 QR codes per bulk creation'),
|
||||
|
|
@ -93,7 +93,7 @@ export const signupSchema = z.object({
|
|||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(100, 'Password must be less than 100 characters'),
|
||||
// Password complexity rules removed for easier testing
|
||||
// Password complexity rules removed for easier testing
|
||||
});
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
|
|
@ -108,7 +108,7 @@ export const resetPasswordSchema = z.object({
|
|||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(100, 'Password must be less than 100 characters'),
|
||||
// Password complexity rules removed for easier testing
|
||||
// Password complexity rules removed for easier testing
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
|
|
@ -129,7 +129,7 @@ export const changePasswordSchema = z.object({
|
|||
newPassword: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(100, 'Password must be less than 100 characters'),
|
||||
// Password complexity rules removed for easier testing
|
||||
// Password complexity rules removed for easier testing
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue