first commit
This commit is contained in:
1
client/dist/assets/index-CB33grvy.css
vendored
Normal file
1
client/dist/assets/index-CB33grvy.css
vendored
Normal file
File diff suppressed because one or more lines are too long
30
client/dist/assets/index-DOfJxXvf.js
vendored
Normal file
30
client/dist/assets/index-DOfJxXvf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
client/dist/index.html
vendored
Normal file
14
client/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>event-qr-tool</title>
|
||||
<script type="module" crossorigin src="/assets/index-DOfJxXvf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CB33grvy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>event-qr-tool</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4245
client/package-lock.json
generated
Normal file
4245
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
client/package.json
Normal file
40
client/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "event-qr-client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/qrcode.react": "^1.0.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"lucide-react": "^0.555.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
30
client/src/App.tsx
Normal file
30
client/src/App.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Home } from './pages/Home';
|
||||
import { Generate } from './pages/Generate';
|
||||
import { Scan } from './pages/Scan';
|
||||
import { Login } from './pages/Login';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/generate" element={<Generate />} />
|
||||
<Route path="/scan" element={<Scan />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
72
client/src/components/Layout.tsx
Normal file
72
client/src/components/Layout.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom';
|
||||
import { QrCode, Scan, Calendar, Mail, LogOut } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export const Layout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<nav className="glass-panel sticky top-0 z-50 border-x-0 border-t-0 rounded-none">
|
||||
<div className="container py-4 flex justify-between items-center">
|
||||
<Link to="/" className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-400 to-purple-400 flex items-center gap-2">
|
||||
<QrCode className="text-indigo-400" />
|
||||
EventQR
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-6 items-center">
|
||||
<Link
|
||||
to="/"
|
||||
className={`flex items-center gap-2 hover:text-indigo-400 transition-colors ${isActive('/') ? 'text-indigo-400' : 'text-slate-400'}`}
|
||||
>
|
||||
<Calendar size={20} />
|
||||
<span className="hidden sm:inline">Events</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/generate"
|
||||
className={`flex items-center gap-2 hover:text-indigo-400 transition-colors ${isActive('/generate') ? 'text-indigo-400' : 'text-slate-400'}`}
|
||||
>
|
||||
<Mail size={20} />
|
||||
<span className="hidden sm:inline">Generate</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/scan"
|
||||
className={`flex items-center gap-2 hover:text-indigo-400 transition-colors ${isActive('/scan') ? 'text-indigo-400' : 'text-slate-400'}`}
|
||||
>
|
||||
<Scan size={20} />
|
||||
<span className="hidden sm:inline">Scan</span>
|
||||
</Link>
|
||||
|
||||
<div className="w-px h-6 bg-slate-700 mx-2"></div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="flex-1 container py-8 page-transition">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="py-6 text-center text-slate-600 text-sm">
|
||||
<p>© 2024 EventQR Tool. Built for seamless event management.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
13
client/src/components/ProtectedRoute.tsx
Normal file
13
client/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export const ProtectedRoute: React.FC = () => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
69
client/src/context/AuthContext.tsx
Normal file
69
client/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
login: (password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = sessionStorage.getItem('eventqr_token');
|
||||
if (token) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (password: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.accessToken) {
|
||||
sessionStorage.setItem('eventqr_token', data.accessToken);
|
||||
setIsAuthenticated(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
sessionStorage.removeItem('eventqr_token');
|
||||
setIsAuthenticated(false);
|
||||
fetch('/api/logout', { method: 'POST' });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
177
client/src/index.css
Normal file
177
client/src/index.css
Normal file
@@ -0,0 +1,177 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent-primary: #6366f1;
|
||||
--accent-secondary: #8b5cf6;
|
||||
--accent-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--glass-bg: rgba(30, 41, 59, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--glass-border);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-transition {
|
||||
/* animation: fadeIn 0.3s ease-in-out; */
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
/* Scanner Overrides */
|
||||
#reader {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
#reader video {
|
||||
border-radius: 0.5rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#reader__scan_region {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
#reader__dashboard_section_csr button {
|
||||
background: var(--accent-gradient) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
margin-top: 1rem !important;
|
||||
}
|
||||
|
||||
#reader__dashboard_section_swaplink {
|
||||
color: var(--accent-primary) !important;
|
||||
text-decoration: none !important;
|
||||
margin-top: 0.5rem !important;
|
||||
display: inline-block !important;
|
||||
}
|
||||
7
client/src/main.tsx
Normal file
7
client/src/main.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
)
|
||||
170
client/src/pages/Generate.tsx
Normal file
170
client/src/pages/Generate.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Save, RefreshCw, QrCode, Mail } from 'lucide-react';
|
||||
import { saveTicket, resendTicketEmail } from '../utils/storage';
|
||||
import type { Ticket } from '../types';
|
||||
|
||||
export const Generate: React.FC = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
type: 'Klassenbester' as Ticket['ticketType']
|
||||
});
|
||||
const [generatedTicket, setGeneratedTicket] = useState<Ticket | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [resendStatus, setResendStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const ticketData = {
|
||||
eventId: 'default-event', // For now, single event
|
||||
attendeeName: formData.name,
|
||||
attendeeEmail: formData.email,
|
||||
ticketType: formData.type,
|
||||
};
|
||||
|
||||
const newTicket = await saveTicket(ticketData);
|
||||
setGeneratedTicket(newTicket);
|
||||
setResendStatus('idle'); // Reset resend status for new ticket
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to generate ticket. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendEmail = async () => {
|
||||
if (!generatedTicket) return;
|
||||
setResendStatus('sending');
|
||||
try {
|
||||
await resendTicketEmail(generatedTicket.id);
|
||||
setResendStatus('sent');
|
||||
setTimeout(() => setResendStatus('idle'), 3000); // Reset after 3 seconds
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setResendStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setGeneratedTicket(null);
|
||||
setFormData({ name: '', email: '', type: 'Klassenbester' });
|
||||
setResendStatus('idle');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Generate Ticket</h1>
|
||||
<p className="text-slate-400">Create a new QR code ticket for an attendee.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="glass-panel p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-slate-300">Attendee Name</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-slate-300">Email Address</label>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-slate-300">Ticket Type</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={e => setFormData({ ...formData, type: e.target.value as any })}
|
||||
>
|
||||
<option value="Klassenbester">Klassenbester</option>
|
||||
<option value="1er Schüler">1er Schüler</option>
|
||||
<option value="Partyborner">Partyborner</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full justify-center mt-4" disabled={isSaving}>
|
||||
{isSaving ? 'Generating...' : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
Generate Ticket
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{generatedTicket ? (
|
||||
<div className="glass-panel p-8 text-center w-full max-w-sm animate-in fade-in zoom-in duration-300">
|
||||
<div className="bg-green-500/10 text-green-400 p-3 rounded-lg mb-6 text-sm font-medium">
|
||||
Ticket generated successfully!
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg inline-block mb-6">
|
||||
<QRCodeSVG
|
||||
value={JSON.stringify({ id: generatedTicket.id, type: generatedTicket.ticketType })}
|
||||
size={200}
|
||||
level="H"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold mb-1">{generatedTicket.attendeeName}</h3>
|
||||
<p className="text-slate-400 text-sm mb-6">{generatedTicket.ticketType} Ticket</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleResendEmail}
|
||||
disabled={resendStatus === 'sending' || resendStatus === 'sent'}
|
||||
className={`w-full justify-center flex items-center gap-2 py-2 rounded-lg transition-colors font-medium ${
|
||||
resendStatus === 'sent'
|
||||
? 'bg-green-500/20 text-green-400 cursor-default'
|
||||
: 'bg-slate-700 hover:bg-slate-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{resendStatus === 'sending' ? (
|
||||
'Sending...'
|
||||
) : resendStatus === 'sent' ? (
|
||||
'Email Sent!'
|
||||
) : (
|
||||
<>
|
||||
<Mail size={18} />
|
||||
Resend Email
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={reset}
|
||||
className="btn-secondary w-full justify-center"
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
Create Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-slate-500 p-12 border-2 border-dashed border-slate-700 rounded-xl">
|
||||
<QrCode size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>Fill out the form to generate a ticket preview here.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
142
client/src/pages/Home.tsx
Normal file
142
client/src/pages/Home.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus, Users, QrCode, Trash2 } from 'lucide-react';
|
||||
import { getTickets, deleteTicket } from '../utils/storage';
|
||||
import type { Ticket } from '../types';
|
||||
|
||||
export const Home: React.FC = () => {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedTickets = await getTickets('default-event'); // For now, single event
|
||||
setTickets(fetchedTickets);
|
||||
} catch (err) {
|
||||
setError('Failed to load tickets.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this ticket?')) {
|
||||
try {
|
||||
await deleteTicket(id);
|
||||
setTickets(tickets.filter(t => t.id !== id));
|
||||
} catch (err) {
|
||||
alert('Failed to delete ticket.');
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Dashboard</h1>
|
||||
<p className="text-slate-400">Manage your events and attendees</p>
|
||||
</div>
|
||||
<Link to="/generate" className="btn-primary">
|
||||
<Plus size={20} />
|
||||
New Ticket
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="glass-panel p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 bg-indigo-500/20 rounded-lg text-indigo-400">
|
||||
<Users size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">Total Attendees</p>
|
||||
<p className="text-2xl font-bold">{tickets.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 bg-purple-500/20 rounded-lg text-purple-400">
|
||||
<QrCode size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">Scanned Tickets</p>
|
||||
<p className="text-2xl font-bold">{tickets.filter(t => t.status === 'used').length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Recent Tickets</h2>
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
Loading tickets...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
No tickets generated yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="text-slate-400 border-b border-slate-700">
|
||||
<th className="p-4">Name</th>
|
||||
<th className="p-4">Email</th>
|
||||
<th className="p-4">Type</th>
|
||||
<th className="p-4">Status</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
{tickets.slice().reverse().map(ticket => (
|
||||
<tr key={ticket.id} className="border-b border-slate-800 hover:bg-slate-800/50">
|
||||
<td className="p-4 font-medium">{ticket.attendeeName}</td>
|
||||
<td className="p-4 text-slate-400">{ticket.attendeeEmail}</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${ticket.ticketType === 'Partyborner' ? 'bg-amber-500/20 text-amber-400' : 'bg-blue-500/20 text-blue-400'
|
||||
}`}>
|
||||
{ticket.ticketType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${ticket.status === 'valid' ? 'bg-green-500/20 text-green-400' : 'bg-slate-500/20 text-slate-400'
|
||||
}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(ticket.id)}
|
||||
className="text-slate-500 hover:text-red-400 transition-colors p-2"
|
||||
title="Delete Ticket"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
65
client/src/pages/Login.tsx
Normal file
65
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await login(password);
|
||||
if (success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setError('Invalid password');
|
||||
}
|
||||
} catch {
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-900 p-4">
|
||||
<div className="glass-panel p-8 w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-block p-4 rounded-full bg-indigo-500/20 text-indigo-400 mb-4">
|
||||
<Lock size={32} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">EventQR Login</h1>
|
||||
<p className="text-slate-400">Please enter password to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="w-full bg-slate-800 border-slate-700 focus:border-indigo-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary w-full justify-center" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
128
client/src/pages/Scan.tsx
Normal file
128
client/src/pages/Scan.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Html5QrcodeScanner } from 'html5-qrcode';
|
||||
import { CheckCircle, XCircle, RefreshCw } from 'lucide-react';
|
||||
import { getTickets, updateTicketStatus } from '../utils/storage';
|
||||
import type { Ticket } from '../types';
|
||||
|
||||
export const Scan: React.FC = () => {
|
||||
const [scanResult, setScanResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
ticket?: Ticket;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent scanner from initializing if we already have a result
|
||||
if (scanResult) return;
|
||||
|
||||
const scanner = new Html5QrcodeScanner(
|
||||
"reader",
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
/* verbose= */ false
|
||||
);
|
||||
|
||||
scanner.render(
|
||||
async (decodedText) => {
|
||||
try {
|
||||
scanner.clear();
|
||||
const data = JSON.parse(decodedText);
|
||||
const tickets = await getTickets('default-event'); // Hardcoded event
|
||||
const ticket = tickets.find(t => t.id === data.id);
|
||||
|
||||
if (ticket) {
|
||||
if (ticket.status === 'valid') {
|
||||
const updatedTicket = await updateTicketStatus(ticket.id, 'used');
|
||||
setScanResult({
|
||||
success: true,
|
||||
message: 'Ticket Valid! Access Granted.',
|
||||
ticket: updatedTicket
|
||||
});
|
||||
} else {
|
||||
setScanResult({
|
||||
success: false,
|
||||
message: 'Ticket already used!',
|
||||
ticket
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setScanResult({
|
||||
success: false,
|
||||
message: 'Invalid Ticket ID',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setScanResult({
|
||||
success: false,
|
||||
message: 'Scan Error: Invalid QR code.',
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// console.warn(error);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
scanner.clear().catch(error => {
|
||||
console.error("Failed to clear html5-qrcode scanner. ", error);
|
||||
});
|
||||
};
|
||||
}, [scanResult]);
|
||||
|
||||
const resetScanner = () => {
|
||||
setScanResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Scan Tickets</h1>
|
||||
<p className="text-slate-400">Use your camera to verify attendee tickets.</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-6 overflow-hidden">
|
||||
{!scanResult ? (
|
||||
<div className="relative">
|
||||
<div id="reader" className="w-full rounded-lg overflow-hidden"></div>
|
||||
<p className="text-center text-sm text-slate-500 mt-4">
|
||||
Point camera at the QR code
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 animate-in fade-in zoom-in duration-300">
|
||||
{scanResult.success ? (
|
||||
<div className="text-green-500 mb-4 flex justify-center">
|
||||
<CheckCircle size={64} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-500 mb-4 flex justify-center">
|
||||
<XCircle size={64} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="text-2xl font-bold mb-2">
|
||||
{scanResult.message}
|
||||
</h2>
|
||||
|
||||
{scanResult.ticket && (
|
||||
<div className="bg-slate-800/50 p-4 rounded-lg inline-block text-left mt-4 mb-6">
|
||||
<p className="text-slate-400 text-sm">Attendee</p>
|
||||
<p className="font-medium text-lg">{scanResult.ticket.attendeeName}</p>
|
||||
<p className="text-slate-400 text-sm mt-2">Type</p>
|
||||
<p className="font-medium text-lg">{scanResult.ticket.ticketType}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button onClick={resetScanner} className="btn-primary">
|
||||
<RefreshCw size={18} />
|
||||
Scan Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
client/src/types/index.ts
Normal file
16
client/src/types/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface Event {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
eventId: string;
|
||||
attendeeName: string;
|
||||
attendeeEmail: string;
|
||||
ticketType: 'Klassenbester' | '1er Schüler' | 'Partyborner';
|
||||
status: 'valid' | 'used';
|
||||
createdAt: string;
|
||||
}
|
||||
69
client/src/utils/storage.ts
Normal file
69
client/src/utils/storage.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Event, Ticket } from '../types';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
const getHeaders = () => {
|
||||
const token = sessionStorage.getItem('eventqr_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getEvents = async (): Promise<Event[]> => {
|
||||
const response = await fetch(`${API_URL}/events`, { headers: getHeaders() });
|
||||
if (!response.ok) throw new Error('Failed to fetch events');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const saveEvent = async (event: Omit<Event, 'id'>): Promise<Event> => {
|
||||
const response = await fetch(`${API_URL}/events`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save event');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getTickets = async (eventId: string): Promise<Ticket[]> => {
|
||||
const response = await fetch(`${API_URL}/tickets?eventId=${eventId}`, { headers: getHeaders() });
|
||||
if (!response.ok) throw new Error('Failed to fetch tickets');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const saveTicket = async (ticket: Omit<Ticket, 'id' | 'status' | 'createdAt'>): Promise<Ticket> => {
|
||||
const response = await fetch(`${API_URL}/tickets`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(ticket),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save ticket');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const resendTicketEmail = async (ticketId: string): Promise<void> => {
|
||||
const response = await fetch(`${API_URL}/tickets/${ticketId}/resend`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to resend ticket email');
|
||||
};
|
||||
|
||||
export const updateTicketStatus = async (ticketId: string, status: 'valid' | 'used'): Promise<Ticket> => {
|
||||
const response = await fetch(`${API_URL}/tickets/${ticketId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update ticket status');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteTicket = async (ticketId: string): Promise<void> => {
|
||||
const response = await fetch(`${API_URL}/tickets/${ticketId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete ticket');
|
||||
};
|
||||
11
client/tailwind.config.js
Normal file
11
client/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
28
client/tsconfig.app.json
Normal file
28
client/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
client/tsconfig.json
Normal file
7
client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
client/tsconfig.node.json
Normal file
26
client/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
client/vite.config.ts
Normal file
15
client/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user