revops + onboarding
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
@@ -11,12 +11,14 @@ import { Select } from '@/components/ui/Select';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
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, Upload, Barcode as BarcodeIcon
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { trackEvent } from '@/components/PostHogProvider';
|
||||
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
||||
import {
|
||||
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon
|
||||
} from 'lucide-react';
|
||||
import Barcode from 'react-barcode';
|
||||
|
||||
// Tooltip component for form field help
|
||||
@@ -99,9 +101,10 @@ function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string
|
||||
return new XMLSerializer().serializeToString(cloned);
|
||||
}
|
||||
|
||||
export default function CreatePage() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
export default function CreatePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -145,14 +148,14 @@ export default function CreatePage() {
|
||||
const [excavate, setExcavate] = useState(true);
|
||||
|
||||
// QR preview
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
|
||||
// Check if user can customize colors (PRO+ only)
|
||||
const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS';
|
||||
|
||||
// Load user plan
|
||||
useEffect(() => {
|
||||
const fetchUserPlan = async () => {
|
||||
useEffect(() => {
|
||||
const fetchUserPlan = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/plan');
|
||||
if (response.ok) {
|
||||
@@ -163,8 +166,44 @@ export default function CreatePage() {
|
||||
console.error('Error fetching user plan:', error);
|
||||
}
|
||||
};
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const queryContentType = searchParams.get('contentType');
|
||||
const useCase = searchParams.get('useCase');
|
||||
const titleParam = searchParams.get('title');
|
||||
const isDynamicParam = searchParams.get('dynamic');
|
||||
|
||||
if (queryContentType) {
|
||||
setContentType(queryContentType);
|
||||
}
|
||||
|
||||
if (titleParam) {
|
||||
setTitle(titleParam);
|
||||
}
|
||||
|
||||
if (isDynamicParam) {
|
||||
setIsDynamic(isDynamicParam === '1');
|
||||
}
|
||||
|
||||
if (useCase === 'menu_pdf') {
|
||||
setContent((prev: any) => ({ ...prev, fileUrl: prev.fileUrl || '' }));
|
||||
} else if (useCase === 'contact_card') {
|
||||
setContent((prev: any) => ({
|
||||
...prev,
|
||||
firstName: prev.firstName || '',
|
||||
lastName: prev.lastName || '',
|
||||
}));
|
||||
} else if (useCase === 'barcode') {
|
||||
setContent((prev: any) => ({
|
||||
...prev,
|
||||
format: prev.format || 'CODE128',
|
||||
}));
|
||||
} else if (queryContentType === 'URL') {
|
||||
setContent((prev: any) => ({ ...prev, url: prev.url || '' }));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const contrast = calculateContrast(foregroundColor, backgroundColor);
|
||||
const hasGoodContrast = contrast >= 4.5;
|
||||
@@ -226,13 +265,19 @@ export default function CreatePage() {
|
||||
const downloadQR = async (format: 'svg' | 'png') => {
|
||||
if (!qrRef.current) return;
|
||||
try {
|
||||
if (format === 'png') {
|
||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
||||
const link = document.createElement('a');
|
||||
link.download = `qrcode-${title || 'download'}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
} else {
|
||||
if (format === 'png') {
|
||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
||||
const link = document.createElement('a');
|
||||
link.download = `qrcode-${title || 'download'}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
trackEvent('qr_code_downloaded', {
|
||||
format: 'png',
|
||||
content_type: contentType,
|
||||
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||
plan: userPlan,
|
||||
});
|
||||
} else {
|
||||
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
|
||||
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
|
||||
// html-to-image can generate SVG too.
|
||||
@@ -254,21 +299,34 @@ export default function CreatePage() {
|
||||
: new XMLSerializer().serializeToString(svgElement);
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `qrcode-${title || 'download'}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} else {
|
||||
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
|
||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
||||
const link = document.createElement('a');
|
||||
link.download = `qrcode-${title || 'download'}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `qrcode-${title || 'download'}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
trackEvent('qr_code_downloaded', {
|
||||
format: 'svg',
|
||||
content_type: contentType,
|
||||
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||
plan: userPlan,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
|
||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
||||
const link = document.createElement('a');
|
||||
link.download = `qrcode-${title || 'download'}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
trackEvent('qr_code_downloaded', {
|
||||
format: 'png',
|
||||
content_type: contentType,
|
||||
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||
plan: userPlan,
|
||||
fallback_from: 'svg_with_frame',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error downloading QR code:', err);
|
||||
showToast('Error downloading QR code', 'error');
|
||||
@@ -354,18 +412,38 @@ export default function CreatePage() {
|
||||
const responseData = await response.json();
|
||||
console.log('RESPONSE DATA:', responseData);
|
||||
|
||||
if (response.ok) {
|
||||
showToast(`QR Code "${title}" created successfully!`, 'success');
|
||||
|
||||
// Wait a moment so user sees the toast, then redirect
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard');
|
||||
router.refresh();
|
||||
}, 1000);
|
||||
} else {
|
||||
console.error('Error creating QR code:', responseData);
|
||||
showToast(responseData.error || 'Error creating QR code', 'error');
|
||||
}
|
||||
if (response.ok) {
|
||||
trackEvent('qr_code_created', {
|
||||
content_type: contentType,
|
||||
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||
plan: userPlan,
|
||||
has_logo: Boolean(logoUrl),
|
||||
frame_type: frameType,
|
||||
});
|
||||
|
||||
showToast(`QR Code "${title}" created successfully!`, 'success');
|
||||
|
||||
// Wait a moment so user sees the toast, then redirect
|
||||
setTimeout(() => {
|
||||
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||
if (searchParams.get('onboarding') === '1') {
|
||||
router.push(appendRedirectParam('/onboarding', redirectTarget, { step: '8' }));
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
router.refresh();
|
||||
}, 1000);
|
||||
} else {
|
||||
console.error('Error creating QR code:', responseData);
|
||||
|
||||
if (response.status === 403 && responseData.error === 'Limit reached') {
|
||||
showToast(responseData.message || 'You have reached your plan limit.', 'error');
|
||||
router.push('/pricing?reason=limit_reached');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(responseData.error || 'Error creating QR code', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating QR code:', error);
|
||||
showToast('Error creating QR code. Please try again.', 'error');
|
||||
@@ -1180,4 +1258,4 @@ export default function CreatePage() {
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import { StatsGrid } from '@/components/dashboard/StatsGrid';
|
||||
import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||
import { QrCode } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||
import { QrCode } from 'lucide-react';
|
||||
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
||||
|
||||
interface QRCodeData {
|
||||
id: string;
|
||||
@@ -44,7 +47,8 @@ export default function DashboardPage() {
|
||||
conversionRate: 0,
|
||||
uniqueScans: 0,
|
||||
});
|
||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||
const [onboardingState, setOnboardingState] = useState<any>(null);
|
||||
|
||||
|
||||
const blogPosts = [
|
||||
@@ -117,12 +121,11 @@ export default function DashboardPage() {
|
||||
// Store in localStorage for consistency
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
|
||||
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
||||
identifyUser(user.id, {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan || 'FREE',
|
||||
provider: 'google',
|
||||
identifyUser(user.id, {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan || 'FREE',
|
||||
provider: 'google',
|
||||
});
|
||||
|
||||
trackEvent(isNewUser ? 'user_signup' : 'user_login', {
|
||||
@@ -143,25 +146,35 @@ export default function DashboardPage() {
|
||||
}, [searchParams, router]);
|
||||
|
||||
// Check for successful payment and verify session
|
||||
useEffect(() => {
|
||||
const success = searchParams.get('success');
|
||||
if (success === 'true') {
|
||||
const verifySession = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stripe/verify-session', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserPlan(data.plan);
|
||||
setUpgradedPlan(data.plan);
|
||||
setShowUpgradeDialog(true);
|
||||
// Remove success parameter from URL
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
console.error('Failed to verify session:', await response.text());
|
||||
}
|
||||
useEffect(() => {
|
||||
const success = searchParams.get('success');
|
||||
const sessionId = searchParams.get('session_id');
|
||||
|
||||
if (success === 'true' && sessionId) {
|
||||
const verifySession = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stripe/verify-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserPlan(data.plan);
|
||||
setUpgradedPlan(data.plan);
|
||||
setShowUpgradeDialog(true);
|
||||
trackEvent('upgrade_completed', {
|
||||
plan: data.plan,
|
||||
source: 'stripe_checkout',
|
||||
});
|
||||
// Remove success parameter from URL
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
console.error('Failed to verify session:', await response.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying session:', error);
|
||||
}
|
||||
@@ -212,13 +225,19 @@ export default function DashboardPage() {
|
||||
setUserPlan(userData.plan || 'FREE');
|
||||
}
|
||||
|
||||
// Fetch analytics data for trends (last 30 days = month comparison)
|
||||
const analyticsResponse = await fetch('/api/analytics/summary?range=30');
|
||||
if (analyticsResponse.ok) {
|
||||
const analytics = await analyticsResponse.json();
|
||||
setAnalyticsData(analytics);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fetch analytics data for trends (last 30 days = month comparison)
|
||||
const analyticsResponse = await fetch('/api/analytics/summary?range=30');
|
||||
if (analyticsResponse.ok) {
|
||||
const analytics = await analyticsResponse.json();
|
||||
setAnalyticsData(analytics);
|
||||
}
|
||||
|
||||
const onboardingResponse = await fetch('/api/onboarding');
|
||||
if (onboardingResponse.ok) {
|
||||
const onboardingData = await onboardingResponse.json();
|
||||
setOnboardingState(onboardingData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
setQrCodes([]);
|
||||
setStats({
|
||||
@@ -341,9 +360,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
{/* Stats Grid */}
|
||||
<OnboardingChecklist state={onboardingState} />
|
||||
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
trends={{
|
||||
totalScans: analyticsData?.summary.scansTrend,
|
||||
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
|
||||
@@ -393,8 +414,8 @@ export default function DashboardPage() {
|
||||
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
|
||||
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
|
||||
You have 3 free dynamic QR codes. They redirect wherever you want and track every scan.
|
||||
</p>
|
||||
You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan.
|
||||
</p>
|
||||
<Link href="/create">
|
||||
<Button>Create QR Code — it takes 90 seconds</Button>
|
||||
</Link>
|
||||
@@ -521,4 +542,4 @@ export default function DashboardPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user