feat: Add Guest QR Generator, HomeNavbar, and enforce SVG tier limits
This commit is contained in:
+3
-3
@@ -50,10 +50,10 @@ The platform operates on a tiered structure, utilizing a robust downgrade logic
|
|||||||
| Feature / Capability | Guest / Free | Free Trial (14-Day) | Pro / Enterprise Paid |
|
| Feature / Capability | Guest / Free | Free Trial (14-Day) | Pro / Enterprise Paid |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| **Static QR Codes** | Unlimited | Unlimited | Unlimited |
|
| **Static QR Codes** | Unlimited | Unlimited | Unlimited |
|
||||||
| **Dynamic QR Codes** | Up to 5 | Unlimited | Unlimited |
|
| **Dynamic QR Codes** | Up to 5 | Up to 1 | Unlimited |
|
||||||
| **Scan Interstitial Ads** | Yes (Ad-supported) | No Ads | No Ads |
|
| **Scan Interstitial Ads** | Yes (Ad-supported) | Yes (Ad-supported) | No Ads |
|
||||||
| **Custom Domains** | CK-QR domain only | Custom subdomains | Custom domain / White-label |
|
| **Custom Domains** | CK-QR domain only | Custom subdomains | Custom domain / White-label |
|
||||||
| **Export Formats** | PNG, JPEG | PNG, JPEG, SVG, PDF | All + Bulk zip export |
|
| **Export Formats** | PNG, JPEG | PNG, JPEG | All + Bulk zip export |
|
||||||
| **File Storage Limit** | 5 MB per upload | 20 MB per upload | 100 MB - 1 GB+ per upload |
|
| **File Storage Limit** | 5 MB per upload | 20 MB per upload | 100 MB - 1 GB+ per upload |
|
||||||
|
|
||||||
**Trial Expiration Logic:**
|
**Trial Expiration Logic:**
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default function CreateQRPage() {
|
|||||||
const [customSlug, setCustomSlug] = useState('');
|
const [customSlug, setCustomSlug] = useState('');
|
||||||
const [randomSlug, setRandomSlug] = useState('');
|
const [randomSlug, setRandomSlug] = useState('');
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [userRole, setUserRole] = useState<string>('free');
|
||||||
const baseUrl = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai') : 'https://qr.houseofwebsites.ai';
|
const baseUrl = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai') : 'https://qr.houseofwebsites.ai';
|
||||||
|
|
||||||
const [qrOptions, setQrOptions] = useState<Options>({
|
const [qrOptions, setQrOptions] = useState<Options>({
|
||||||
@@ -55,6 +56,13 @@ export default function CreateQRPage() {
|
|||||||
} else {
|
} else {
|
||||||
setQrOptions(prev => ({ ...prev, data: destinationUrl }));
|
setQrOptions(prev => ({ ...prev, data: destinationUrl }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch user role
|
||||||
|
api.get('/auth/me').then(res => {
|
||||||
|
if (res.data?.user?.role) {
|
||||||
|
setUserRole(res.data.user.role);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
}, [baseUrl]);
|
}, [baseUrl]);
|
||||||
|
|
||||||
// Keep QR data in sync with type and destinationUrl
|
// Keep QR data in sync with type and destinationUrl
|
||||||
@@ -124,9 +132,15 @@ export default function CreateQRPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownload('svg')}
|
onClick={() => handleDownload('svg')}
|
||||||
className="bg-white border border-gray-300 text-gray-700 px-6 py-2.5 rounded-lg hover:bg-gray-50 transition-colors font-semibold shadow-sm"
|
disabled={userRole === 'free' || userRole === 'free_trial'}
|
||||||
|
className={`px-6 py-2.5 rounded-lg font-semibold shadow-sm transition-colors border border-gray-300 ${
|
||||||
|
userRole === 'free' || userRole === 'free_trial'
|
||||||
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-white text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
title={userRole === 'free' || userRole === 'free_trial' ? 'SVG export is for Pro users' : 'Download SVG'}
|
||||||
>
|
>
|
||||||
Download SVG
|
{userRole === 'free' || userRole === 'free_trial' ? 'Download SVG (Pro)' : 'Download SVG'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface QRCode {
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [qrs, setQrs] = useState<QRCode[]>([]);
|
const [qrs, setQrs] = useState<QRCode[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [userRole, setUserRole] = useState<string>('free');
|
||||||
|
|
||||||
// Edit Modal State
|
// Edit Modal State
|
||||||
const [editingQr, setEditingQr] = useState<QRCode | null>(null);
|
const [editingQr, setEditingQr] = useState<QRCode | null>(null);
|
||||||
@@ -27,10 +28,16 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const fetchQRs = async () => {
|
const fetchQRs = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/qr');
|
const [res, authRes] = await Promise.all([
|
||||||
|
api.get('/qr'),
|
||||||
|
api.get('/auth/me').catch(() => null)
|
||||||
|
]);
|
||||||
setQrs(res.data.qr_codes || []);
|
setQrs(res.data.qr_codes || []);
|
||||||
|
if (authRes?.data?.user?.role) {
|
||||||
|
setUserRole(authRes.data.user.role);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch QRs", err);
|
console.error("Failed to fetch data", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -197,10 +204,15 @@ export default function DashboardPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownload(qr, 'svg')}
|
onClick={() => handleDownload(qr, 'svg')}
|
||||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-2 rounded-lg transition-colors flex justify-center items-center text-sm shadow-sm"
|
disabled={userRole === 'free' || userRole === 'free_trial'}
|
||||||
title="Download SVG"
|
className={`flex-1 font-bold py-2 px-2 rounded-lg transition-colors flex justify-center items-center text-sm shadow-sm ${
|
||||||
|
userRole === 'free' || userRole === 'free_trial'
|
||||||
|
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||||
|
: 'bg-green-500 hover:bg-green-600 text-white'
|
||||||
|
}`}
|
||||||
|
title={userRole === 'free' || userRole === 'free_trial' ? 'SVG export is for Pro users' : 'Download SVG'}
|
||||||
>
|
>
|
||||||
SVG
|
{userRole === 'free' || userRole === 'free_trial' ? 'SVG (Pro)' : 'SVG'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+11
-21
@@ -1,31 +1,13 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import HomeNavbar from "@/components/HomeNavbar";
|
||||||
|
import GuestQRGenerator from "@/components/GuestQRGenerator";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 flex flex-col font-sans text-gray-100">
|
<div className="min-h-screen bg-gray-900 flex flex-col font-sans text-gray-100">
|
||||||
|
|
||||||
{/* Navigation */}
|
<HomeNavbar />
|
||||||
<nav className="fixed w-full z-50 bg-gray-900/80 backdrop-blur-md border-b border-gray-800">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex justify-between h-20 items-center">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/30">
|
|
||||||
<span className="text-white font-black text-xl tracking-tighter">CQ</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-extrabold text-2xl tracking-tight text-white">CK-QR</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<Link href="/login" className="text-gray-300 hover:text-white font-medium px-4 transition-colors">
|
|
||||||
Login
|
|
||||||
</Link>
|
|
||||||
<Link href="/register" className="bg-white text-gray-900 px-6 py-2.5 rounded-full font-bold hover:bg-gray-100 transition-colors shadow-lg hover:shadow-white/20">
|
|
||||||
Start Free Trial
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Hero Section with Parallax */}
|
{/* Hero Section with Parallax */}
|
||||||
<div
|
<div
|
||||||
@@ -77,6 +59,14 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Guest Static QR Generator Section */}
|
||||||
|
<div className="bg-gray-950 py-20 relative z-10">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<GuestQRGenerator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Trust & Scale Metrics (Data Section) */}
|
{/* Trust & Scale Metrics (Data Section) */}
|
||||||
<div className="bg-gray-900 border-y border-gray-800 py-12">
|
<div className="bg-gray-900 border-y border-gray-800 py-12">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
// Load LivePreview dynamically since it depends on browser APIs
|
||||||
|
const LivePreview = dynamic(() => import('@/components/qr-editor/LivePreview'), { ssr: false });
|
||||||
|
|
||||||
|
export default function GuestQRGenerator() {
|
||||||
|
const [url, setUrl] = useState('https://ck-qr.com');
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
type: 'svg' as const,
|
||||||
|
data: url || 'https://ck-qr.com',
|
||||||
|
margin: 10,
|
||||||
|
qrOptions: {
|
||||||
|
typeNumber: 0 as const,
|
||||||
|
mode: 'Byte' as const,
|
||||||
|
errorCorrectionLevel: 'Q' as const
|
||||||
|
},
|
||||||
|
imageOptions: {
|
||||||
|
hideBackgroundDots: true,
|
||||||
|
imageSize: 0.4,
|
||||||
|
margin: 5
|
||||||
|
},
|
||||||
|
dotsOptions: {
|
||||||
|
color: '#4f46e5',
|
||||||
|
type: 'rounded' as const
|
||||||
|
},
|
||||||
|
backgroundOptions: {
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
cornersSquareOptions: {
|
||||||
|
color: '#4f46e5',
|
||||||
|
type: 'extra-rounded' as const
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const QRCodeStyling = (await import('qr-code-styling')).default;
|
||||||
|
const qrCode = new QRCodeStyling(defaultOptions);
|
||||||
|
qrCode.download({ name: 'Guest-Static-QR', extension: 'png' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 p-8 rounded-3xl lg:p-12 shadow-2xl relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/10 rounded-full blur-3xl"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-12 items-center relative z-10">
|
||||||
|
<div className="flex-1 w-full text-center lg:text-left">
|
||||||
|
<h2 className="text-3xl lg:text-4xl font-black text-white mb-4">Try it now, for free.</h2>
|
||||||
|
<p className="text-gray-400 text-lg mb-8">
|
||||||
|
Enter a destination URL below to instantly generate a basic static QR code. No signup required.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://yourwebsite.com"
|
||||||
|
className="w-full bg-gray-950 border border-gray-700 text-white rounded-xl px-4 py-4 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex-1 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-white font-bold py-3 px-6 rounded-xl transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Download PNG
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-6 rounded-xl transition-colors shadow-lg shadow-indigo-500/25 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
Track Scans (Free Trial)
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Static QR codes cannot be edited later and do not track scan analytics.
|
||||||
|
<Link href="/register" className="text-indigo-400 hover:text-indigo-300 font-medium ml-1">Upgrade to track.</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-1/3 flex justify-center">
|
||||||
|
<div className="pointer-events-none transform scale-90 sm:scale-100">
|
||||||
|
<LivePreview options={defaultOptions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
export default function HomeNavbar() {
|
||||||
|
const [user, setUser] = useState<{ email: string; role: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user is logged in
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/auth/me');
|
||||||
|
if (res.data?.user) {
|
||||||
|
setUser(res.data.user);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Not logged in or error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed w-full z-50 bg-gray-900/80 backdrop-blur-md border-b border-gray-800">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between h-20 items-center">
|
||||||
|
<Link href="/" className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/30">
|
||||||
|
<span className="text-white font-black text-xl tracking-tighter">CQ</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-extrabold text-2xl tracking-tight text-white">CK-QR</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Link href="/dashboard" className="text-gray-300 hover:text-white font-medium px-4 transition-colors flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-indigo-600 text-white rounded-full flex items-center justify-center text-sm font-bold uppercase">
|
||||||
|
{user.email.charAt(0)}
|
||||||
|
</div>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/login" className="text-gray-300 hover:text-white font-medium px-4 transition-colors">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link href="/register" className="bg-white text-gray-900 px-6 py-2.5 rounded-full font-bold hover:bg-gray-100 transition-colors shadow-lg hover:shadow-white/20">
|
||||||
|
Start Free Trial
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user