first commit

This commit is contained in:
DerJesen
2025-11-29 12:26:58 +01:00
parent 2fae31f20f
commit fe5bbc1410
142 changed files with 19585 additions and 1 deletions

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

File diff suppressed because one or more lines are too long

14
client/dist/index.html vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

40
client/package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

30
client/src/App.tsx Normal file
View 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;

View 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>
);
};

View 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 />;
};

View 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
View 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
View 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 />
)

View 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
View 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>
);
};

View 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
View 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
View 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;
}

View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
client/tsconfig.node.json Normal file
View 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
View 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,
}
}
}
})