40 Commits

Author SHA1 Message Date
Roman Krček
a38edfbc07 Finalize transition to ScanWave and add dev dependecies 2025-06-25 15:11:40 +02:00
Roman Krček
6c2e80ecc1 Kill the QRcode scanner when exiting the view 2025-06-25 14:54:06 +02:00
Roman Krček
c28c338de4 Styling changes and rebrand to ScanWave 2025-06-25 14:53:51 +02:00
Roman Krček
906f0376d2 Event overview panel 2025-06-25 14:16:45 +02:00
Roman Krček
52c3b9c871 Creator fully working POC 2025-06-25 13:26:02 +02:00
Roman Krček
ea985c21a9 Improvements to creator flow 2025-06-25 00:03:26 +02:00
Roman Krček
c063c1c5c4 Improved UX for the creation flow 2025-06-24 17:46:35 +02:00
Roman Krček
297641ee2d Disable button if not all conditions are satisfied 2025-06-24 17:30:34 +02:00
Roman Krček
2c2ee18fa3 Remove debugging 2025-06-24 17:21:30 +02:00
Roman Krček
c24a845999 Test form for sending emails 2025-06-24 14:50:04 +02:00
Roman Krček
be2e22100f Proper email attachment handling 2025-06-24 14:49:07 +02:00
Roman Krček
e2e18d1084 Testing sending mails 2025-06-24 14:06:03 +02:00
Roman Krček
ac4c86aaa9 HTML for emails 2025-06-24 14:05:54 +02:00
Roman Krček
2c498a62ee Styling of creator finished 2025-06-24 13:52:04 +02:00
Roman Krček
a42d102c4d Styling of creator progress 2025-06-24 12:45:05 +02:00
Roman Krček
0fdf77a8c3 Creator scaffolding 2025-06-23 18:07:23 +02:00
Roman Krček
864c77133e remove debugging 2025-06-23 14:38:55 +02:00
Roman Krček
0a60ea7ffb Fix validation fo google tokens 2025-06-23 14:28:44 +02:00
Roman Krček
1760448f73 More signout fixes 2025-06-23 14:18:05 +02:00
Roman Krček
fe8789af87 Fixed signing out 2025-06-23 14:09:14 +02:00
Roman Krček
2f41e5ba1a Fix google env variables 2025-06-22 17:58:54 +02:00
Roman Krček
fd12f22aa7 Clear token on signout
All checks were successful
Build Docker image / build (pull_request) Successful in 4m39s
2025-06-22 17:47:55 +02:00
Roman Krček
14213fe341 Gmail working 2025-06-22 16:55:33 +02:00
Roman Krček
620c9c5cb7 better sighnout and more user info 2025-06-22 16:16:55 +02:00
Roman Krček
23b079907f styling for ticket display 2025-06-22 16:16:39 +02:00
Roman Krček
4ad4d1a04c creator navigation 2025-06-22 16:16:29 +02:00
Roman Krček
34afb21c22 added google packages 2025-06-22 16:16:18 +02:00
Roman Krček
a925fd70f5 signout route 2025-06-22 16:16:07 +02:00
Roman Krček
8cc59b89da Looking much better
All checks were successful
Build Docker image / build (pull_request) Successful in 1m47s
2025-06-21 23:34:49 +02:00
Roman Krček
4468d03103 Fix cookie vulnerability 2025-06-21 22:51:40 +02:00
Roman Krček
f8fe8be1bf Fix missing env variable during build time
Some checks failed
Build Docker image / build (pull_request) Has been cancelled
2025-06-21 22:39:53 +02:00
Roman Krček
2c11e82424 Styling 2025-06-21 22:09:34 +02:00
Roman Krček
21ed61f0c2 Working nice, still looking kinda shit 2025-06-21 22:07:54 +02:00
Roman Krček
b6d9b8df44 Looking better 2025-06-21 21:35:52 +02:00
Roman Krček
a135c5ac36 Working nice, looks like shit 2025-06-21 20:47:22 +02:00
Roman Krček
58872bada6 Working 2025-06-19 20:25:36 +02:00
Roman Krček
9c94f9c717 Slightly working 2025-06-19 19:42:08 +02:00
Roman Krček
51dbfcc1bb Inital supabase client set up 2025-05-30 09:31:21 +02:00
Roman Krček
72713ff5d7 Add supabase 2025-05-18 19:39:50 +02:00
Roman Krček
26fa74ca79 Change project structure 2025-05-18 19:39:39 +02:00
50 changed files with 2923 additions and 105 deletions

View File

@@ -1,2 +1,3 @@
node_modules/
.svelte-kit/
.svelte-kit/
.env

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
PUBLIC_SUPABASE_URL=https://abc.supabase.co
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s

View File

@@ -46,7 +46,7 @@ jobs:
org.opencontainers.image.revision=${{ env.GITHUB_SHA }}
org.opencontainers.image.vendor=Orebolt.cz
org.opencontainers.image.ref.name=${{ env.GITHUB_REF }}
org.opencontainers.image.title=ESN Code Scanner App
org.opencontainers.image.title=ScanWave
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.24.0

12
docker-compose-prod.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
app:
image: ${DOCKER_REGISTRY}/${DOCKER_USER}/${DOCKER_IMAGE}:latest
restart: unless-stopped
env_file: .env
labels:
- "traefik.enable=true"
- "traefik.http.routers.scan-wave.rule=Host(`scanwave.orebolt.cz`)"
- "traefik.http.routers.scan-wave.tls.certresolver=leresolver"
- "traefik.http.routers.scan-wave.entrypoints=websecure"
- "traefik.http.services.scan-wave.loadbalancer.server.port=3000"
- "traefik.http.routers.scan-wave.middlewares=hsts"

View File

@@ -1,7 +1,7 @@
---
services:
app:
image: ${DOCKER_REGISTRY}/${DOCKER_USER}/esn-code-scanner-app:latest
image: ${DOCKER_REGISTRY}/${DOCKER_USER}/${DOCKER_IAMGE}:latest
restart: unless-stopped
ports:
- "3000:3000"

1176
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "esn-code-scanner",
"name": "scan-wave",
"private": true,
"version": "0.0.1",
"type": "module",
@@ -14,7 +14,7 @@
"lint": "prettier --check ."
},
"devDependencies": {
"@sveltejs/kit": "^2.16.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
@@ -26,9 +26,16 @@
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.2.6"
"vite-plugin-devtools-json": "^0.2.0"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.2.12"
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12",
"googleapis": "^150.0.1",
"papaparse": "^5.5.3",
"qrcode": "^1.5.4",
"quoted-printable": "^1.0.1",
"simple-icons": "^15.3.0"
}
}

View File

@@ -1,2 +1 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@import 'tailwindcss';

28
src/app.d.ts vendored
View File

@@ -1,13 +1,21 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { Session, SupabaseClient, User } from '@supabase/supabase-js'
import type { Database } from './database.types.ts' // import generated types
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
interface Locals {
supabase: SupabaseClient<Database>
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>
session: Session | null
user: User | null
}
interface PageData {
session: Session | null
}
// interface PageState {}
// interface Platform {}
}
}
export {};
export {}

View File

@@ -10,3 +10,11 @@
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
<style>
body {
font-family: "Roboto", sans-serif;
}
</style>

81
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,81 @@
import { createServerClient } from '@supabase/ssr'
import { type Handle, redirect } from '@sveltejs/kit'
import { sequence } from '@sveltejs/kit/hooks'
import { env } from '$env/dynamic/public'
const supabase: Handle = async ({ event, resolve }) => {
/**
* Creates a Supabase client specific to this server request.
*
* The Supabase client gets the Auth token from the request cookies.
*/
event.locals.supabase = createServerClient(env.PUBLIC_SUPABASE_URL, env.PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll: () => event.cookies.getAll(),
/**
* SvelteKit's cookies API requires `path` to be explicitly set in
* the cookie options. Setting `path` to `/` replicates previous/
* standard behavior.
*/
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' })
})
},
},
})
/**
* Unlike `supabase.auth.getSession()`, which returns the session _without_
* validating the JWT, this function also calls `getUser()` to validate the
* JWT before returning the session.
*/
event.locals.safeGetSession = async () => {
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
if (!session) {
return { session: null, user: null }
}
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser()
if (error) {
// JWT validation has failed
return { session: null, user: null }
}
return { session, user }
}
return resolve(event, {
filterSerializedResponseHeaders(name) {
/**
* Supabase libraries use the `content-range` and `x-supabase-api-version`
* headers, so we need to tell SvelteKit to pass it through.
*/
return name === 'content-range' || name === 'x-supabase-api-version'
},
})
}
const authGuard: Handle = async ({ event, resolve }) => {
const { session, user } = await event.locals.safeGetSession()
event.locals.session = session
event.locals.user = user
if (!event.locals.session && event.url.pathname.startsWith('/private')) {
redirect(303, '/auth')
}
if (event.locals.session && event.url.pathname === '/auth') {
redirect(303, '/private/home')
}
return resolve(event)
}
export const handle: Handle = sequence(supabase, authGuard)

103
src/lib/google.ts Normal file
View File

@@ -0,0 +1,103 @@
import { google } from 'googleapis';
import { env } from '$env/dynamic/private';
import quotedPrintable from 'quoted-printable'; // tiny, zero-dep package
export const scopes = ['https://www.googleapis.com/auth/gmail.send'];
export function getOAuthClient() {
return new google.auth.OAuth2(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
env.GOOGLE_REDIRECT_URI
);
}
export function createAuthUrl() {
return getOAuthClient().generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: scopes
});
}
export async function exchangeCodeForTokens(code: string) {
const { tokens } = await getOAuthClient().getToken(code);
if (!tokens.refresh_token) throw new Error('No refresh_token returned');
return tokens.refresh_token;
}
export async function sendGmail(
refreshToken: string,
{ to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string }
) {
const oauth = getOAuthClient();
oauth.setCredentials({ refresh_token: refreshToken });
const gmail = google.gmail({ version: 'v1', auth: oauth });
const message_html =
`<!DOCTYPE html>
<html lang="en">
<head>
<style>
@import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
</style>
</head>
<body style="font-family: 'Lato', sans-serif; background-color: #f9f9f9; padding: 20px; margin: 0;">
<div style="max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
<p style="white-space: pre-line;font-size: 16px; line-height: 1.5; color: #333;">${text}</p>
<img src="cid:qrCode1" alt="QR Code" style="display: block; margin: 20px auto; max-width: 50%; min-width: 200px; height: auto;" />
<div style="width: 100%; display: flex; flex-direction: row; justify-content: space-between">
<div style="height: 4px; width: 20%; background: #00aeef;"></div>
<div style="height: 4px; width: 20%; background: #ec008c;"></div>
<div style="height: 4px; width: 20%; background: #7ac143;"></div>
<div style="height: 4px; width: 20%; background: #f47b20;"></div>
<div style="height: 4px; width: 20%; background: #2e3192;"></div>
</div>
<div style="font-size: 12px; color: #999; padding-top: 0px; margin-top: 10px; line-height: 1.5; ">
<p>This email has been generated with the help of *insert software name*</p>
</div>
</div>
</body>
</html>`;
const boundary = 'BOUNDARY';
const nl = '\r\n'; // RFC-5322 line ending
const htmlQP = quotedPrintable.encode(message_html);
const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl);
const rawParts = [
'MIME-Version: 1.0',
`To: ${to}`,
`Subject: ${subject}`,
`Content-Type: multipart/related; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'Content-Transfer-Encoding: quoted-printable',
'',
htmlQP,
'',
`--${boundary}`,
'Content-Type: image/png',
'Content-Transfer-Encoding: base64',
'Content-ID: <qrCode1>',
'Content-Disposition: inline; filename="qr.png"',
'',
qrLines,
'',
`--${boundary}--`,
''
];
const rawMessage = rawParts.join(nl);
const raw = Buffer.from(rawMessage).toString('base64url');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw }
});
}

31
src/lib/types.ts Normal file
View File

@@ -0,0 +1,31 @@
export enum ScanState {
scanning,
scan_successful,
scan_failed
}
export type TicketData = {
id: string;
name: string;
surname: string;
email: string;
event: { id: string; name: string };
created_at: string;
created_by: { id: string; display_name: string } | null;
scanned: boolean;
scanned_at: string | null;
scanned_by: { id: string; display_name: string } | null;
};
export const defaultTicketData: TicketData = {
id: '',
name: '',
surname: '',
email: '',
event: '',
created_at: new Date().toISOString(),
created_by: null,
scanned: false,
scanned_at: null,
scanned_by: null,
};

View File

@@ -0,0 +1,9 @@
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
const { session } = await safeGetSession()
return {
session,
cookies: cookies.getAll(),
}
}

View File

@@ -1,7 +1,24 @@
<script lang="ts">
<script>
import { invalidate } from '$app/navigation';
import { onMount } from 'svelte';
import '../app.css';
let { children } = $props();
let { data, children } = $props();
let { session, supabase } = $derived(data);
onMount(() => {
const { data } = supabase.auth.onAuthStateChange((_, newSession) => {
if (newSession?.expires_at !== session?.expires_at) {
invalidate('supabase:auth');
}
});
return () => data.subscription.unsubscribe();
});
</script>
<svelte:head>
<title>ScanWave</title>
</svelte:head>
{@render children()}

43
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,43 @@
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'
import { env } from '$env/dynamic/public'
import type { LayoutLoad } from './$types'
export const load: LayoutLoad = async ({ data, depends, fetch }) => {
/**
* Declare a dependency so the layout can be invalidated, for example, on
* session refresh.
*/
depends('supabase:auth')
const supabase = isBrowser()
? createBrowserClient(env.PUBLIC_SUPABASE_URL, env.PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch,
},
})
: createServerClient(env.PUBLIC_SUPABASE_URL, env.PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch,
},
cookies: {
getAll() {
return data.cookies
},
},
})
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
* safe, and on the server, it reads `session` from the `LayoutData`, which
* safely checked the session using `safeGetSession`.
*/
const {
data: { session },
} = await supabase.auth.getSession()
const {
data: { user },
} = await supabase.auth.getUser()
return { session, supabase, user }
}

View File

@@ -1,68 +1,13 @@
<script>
import { onMount, tick } from 'svelte';
import QRScanner from './QRScanner.svelte';
let scanned_id = $state('');
let scan_user = $state('Roman');
let scan_data = $state({});
let ticket_state = $state('not_scanned');
onMount(() => {reset_scan_data()});
function reset_scan_data() {
ticket_state = 'unknown';
scan_data = {
id: 0,
created_at: 'none',
name: 'none',
surname: 'none',
email: 'none@esnvutbrno.cz',
uuid: 'none',
scanned: 'none',
scanned_at: 'none',
event_name: 'none',
scanned_by: 'none'
};
}
$effect(() => {
if (scan_data.scanned === true) {
ticket_state = 'Already scanned';
} else if (scan_data.scanned === false) {
ticket_state = 'Good to go in 2';
console.log(scan_data.scanned);
} else {
ticket_state = 'Ticket invalid';
}
});
$effect(() => {
console.log('Message updated:', scanned_id);
reset_scan_data();
fetch('https://n8n.orebolt.cz/webhook/9d32752c-47c9-46db-be6d-f473e97a7c25', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ scanned_id, scan_user })
})
.then((response) => response.json())
.then((data) => {
scan_data = data;
console.log('Success:', data);
})
.catch((error) => {
console.error('Error:', error);
ticket_state = "Ticket invalid";
});
});
</script>
<QRScanner bind:message={scanned_id} />
<p>CODE: {scanned_id}</p>
<p>Name: {scan_data.name} {scan_data.surname}</p>
<p>State: {ticket_state}</p>
<p>Event: {scan_data.event_name}</p>
<div class="min-h-screen flex flex-col justify-center items-center">
<!-- SVG QR Code Art on Top -->
<div class="mb-8">
<img class="w-32 h-auto" src="/qr-code.png" alt="">
</div>
<h1 class="text-3xl font-bold text-center mb-2">ScanWave</h1>
<h2 class="text-lg text-gray-600 text-center mb-8">Make entrance to your events a breeze.</h2>
<div class="flex space-x-4 w-full justify-center">
<a href="/private/home" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-full shadow-none border border-gray-300 w-64 text-center transition">
Get started
</a>
</div>
</div>

View File

@@ -0,0 +1,14 @@
<div class="flex items-center justify-center min-h-screen">
<div class="flex flex-col space-y-4">
<a href="/auth/login">
<button class="w-40 py-2 bg-white border border-gray-300 text-gray-800 rounded hover:border-gray-400 transition">
Login
</button>
</a>
<a href="/auth/singup">
<button class="w-40 py-2 bg-white border border-gray-300 text-gray-800 rounded hover:border-gray-400 transition">
Signup
</button>
</a>
</div>
</div>

View File

@@ -0,0 +1,31 @@
import type { EmailOtpType } from '@supabase/supabase-js'
import { redirect } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async ({ url, locals: { supabase } }) => {
const token_hash = url.searchParams.get('token_hash')
const type = url.searchParams.get('type') as EmailOtpType | null
const next = url.searchParams.get('next') ?? '/'
/**
* Clean up the redirect URL by deleting the Auth flow parameters.
*
* `next` is preserved for now, because it's needed in the error case.
*/
const redirectTo = new URL(url)
redirectTo.pathname = next
redirectTo.searchParams.delete('token_hash')
redirectTo.searchParams.delete('type')
if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({ type, token_hash })
if (!error) {
redirectTo.searchParams.delete('next')
redirect(303, redirectTo)
}
}
redirectTo.pathname = '/auth/error'
redirect(303, redirectTo)
}

View File

@@ -0,0 +1 @@
<p>Login error</p>

View File

@@ -0,0 +1,41 @@
import { redirect } from '@sveltejs/kit'
import type { Actions } from './$types'
export const actions: Actions = {
signup: async ({ request, locals: { supabase } }) => {
const formData = await request.formData()
const email = formData.get('email') as string
const password = formData.get('password') as string
const display_name = formData.get('display_name') as string
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
display_name: display_name
}
}
});
if (error) {
console.error(error)
redirect(303, '/auth/error')
} else {
redirect(303, '/')
}
},
login: async ({ request, locals: { supabase } }) => {
const formData = await request.formData()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
console.error(error)
redirect(303, '/auth/error')
} else {
redirect(303, '/private/home')
}
},
}

View File

@@ -0,0 +1,33 @@
<div class="flex items-center justify-center min-h-screen bg-gray-50">
<form method="POST" action="?/login" class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-80 shadow-none">
<h2 class="text-2xl font-semibold text-center mb-4">Login</h2>
<label class="flex flex-col text-gray-700">
Email
<input
name="email"
type="email"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200"
required
/>
</label>
<label class="flex flex-col text-gray-700">
Password
<input
name="password"
type="password"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200"
required
/>
</label>
<button
type="submit"
class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
>
Login
</button>
<div class="text-center text-sm text-gray-500 mt-2">
Don't have an account?
<a href="/auth/signup" class="text-blue-600 hover:underline">Signup</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,11 @@
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals}) => {
await locals.supabase.auth.signOut();
const html = `
<script>
localStorage.clear();
location.href = '/';
</script>`;
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
};

View File

@@ -0,0 +1,41 @@
import { redirect } from '@sveltejs/kit'
import type { Actions } from './$types'
export const actions: Actions = {
signup: async ({ request, locals: { supabase } }) => {
const formData = await request.formData()
const email = formData.get('email') as string
const password = formData.get('password') as string
const display_name = formData.get('display_name') as string
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
display_name: display_name
}
}
});
if (error) {
console.error(error)
redirect(303, '/auth/error')
} else {
redirect(303, '/')
}
},
login: async ({ request, locals: { supabase } }) => {
const formData = await request.formData()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
console.error(error)
redirect(303, '/auth/error')
} else {
redirect(303, '/private/home')
}
},
}

View File

@@ -0,0 +1,43 @@
<div class="flex items-center justify-center min-h-screen bg-gray-50">
<form method="POST" action="?/login" class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-80 shadow-none">
<h2 class="text-2xl font-semibold text-center mb-4">Sign Up</h2>
<label class="flex flex-col text-gray-700">
Email
<input
name="email"
type="email"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200"
required
/>
</label>
<label class="flex flex-col text-gray-700">
Password
<input
name="password"
type="password"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200"
required
/>
</label>
<label class="flex flex-col text-gray-700">
Display name
<input
name="display_name"
type="text"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200"
required
/>
</label>
<button
type="submit"
formaction="?/signup"
class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
>
Sign up
</button>
<div class="text-center text-sm text-gray-500 mt-2">
Already have an account?
<a href="/auth/login" class="text-blue-600 hover:underline">Login</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,5 @@
/**
* This file is necessary to ensure protection of all routes in the `private`
* directory. It makes the routes in this directory _dynamic_ routes, which
* send a server request, and thus trigger `hooks.server.ts`.
**/

View File

@@ -0,0 +1,21 @@
<script lang="ts">
// Add any navbar logic here if needed
</script>
<nav class="bg-gray-50 border-b border-gray-300 text-gray-900 p-2">
<div class="container max-w-2xl mx-auto p-2">
<div class="flex items-center justify-between">
<div class="font-bold text-lg">ScanWave</div>
<ul class="flex space-x-4">
<li><a href="/private/home" class="hover:underline">Home</a></li>
<li><a href="/private/scanner" class="hover:underline">Scanner</a></li>
<li><a href="/private/events" class="hover:underline">Events</a></li>
</ul>
</div>
</div>
</nav>
<div class="container max-w-2xl mx-auto p-2 bg-white">
<slot />
</div>

View File

@@ -0,0 +1,79 @@
import type { RequestHandler } from './$types';
import { json, redirect } from '@sveltejs/kit';
import {
createAuthUrl,
exchangeCodeForTokens,
sendGmail,
getOAuthClient
} from '$lib/google';
/* ───────────── GET ───────────── */
export const GET: RequestHandler = async ({ url }) => {
/* 1. /private/api/gmail?action=auth → 302 to Google */
if (url.searchParams.get('action') === 'auth') {
throw redirect(302, createAuthUrl());
}
/* 2. Google callback /private/api/gmail?code=XXXX */
const code = url.searchParams.get('code');
if (code) {
try {
const refreshToken = await exchangeCodeForTokens(code);
const html = `
<script>
localStorage.setItem('gmail_refresh_token', ${JSON.stringify(refreshToken)});
location = '/private/creator';
</script>`;
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
} catch (err) {
return new Response((err as Error).message, { status: 500 });
}
}
return new Response('Bad request', { status: 400 });
};
/* ───────────── POST ───────────── */
export const POST: RequestHandler = async ({ request }) => {
const { action, refreshToken, to, subject, text, qr_code } = await request.json();
/* send e-mail */
if (action === 'send') {
if (!refreshToken) return new Response('Missing token', { status: 401 });
try {
await sendGmail(refreshToken, { to, subject, text, qr_code });
return json({ ok: true });
} catch (err) {
return new Response((err as Error).message, { status: 500 });
}
}
/* revoke token */
if (action === 'revoke') {
if (!refreshToken) return new Response('Missing token', { status: 401 });
try {
await getOAuthClient().revokeToken(refreshToken);
return json({ ok: true });
} catch (err) {
return new Response((err as Error).message, { status: 500 });
}
}
/* validate token */
if (action === 'validate') {
if (!refreshToken) {
return json({ valid: false });
}
try {
const oAuth2Client = getOAuthClient();
oAuth2Client.setCredentials({ refresh_token: refreshToken });
await oAuth2Client.getAccessToken(); // This will throw if invalid
return json({ valid: true });
} catch (err) {
return json({ valid: false, error: (err as Error).message });
}
}
return new Response('Bad request', { status: 400 });
};

View File

@@ -0,0 +1,61 @@
import type { Actions } from './$types';
import { error as kitError } from '@sveltejs/kit';
import Papa from 'papaparse';
import { fail } from '@sveltejs/kit';
export async function load({ locals }) {
const { data: events, error } = await locals.supabase
.from('events')
.select('*')
.order('date', { ascending: true });
if (error) {
console.error('❌ supabase error:', error);
// optional: throw to render SvelteKit error page
throw kitError(500, 'Could not load events');
}
return { events };
}
export const actions = {
create: async (event) => {
const formData = await event.request.formData();
let { data: new_event, error } = await event.locals.supabase.rpc("create_event",
{
"p_name": formData.get('name'),
"p_date": formData.get('date'),
"p_description": formData.get('description'),
});
return {
new_event,
error
}
},
participants: async (event) => {
const formData = await event.request.formData();
const file = formData.get('participants') as File;
let csvText = await file.text();
const { data: parsedRows, errors } = Papa.parse<string[]>(csvText, {
skipEmptyLines: true,
header: false
});
// Remove the first row (header)
if (parsedRows.length > 0) {
parsedRows.shift();
}
// Map each row to an object with keys: name, surname, email
const participants = parsedRows.map((row: string[]) => ({
name: row[0],
surname: row[1],
email: row[2]
}));
return {
participants,
}
}
} satisfies Actions;

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import StepConnectGoogle from "./steps/StepConnectGoogle.svelte";
import StepCraftEmail from "./steps/StepCraftEmail.svelte";
import StepCreateEvent from "./steps/StepCreateEvent.svelte";
import StepOverview from "./steps/StepOverview.svelte";
import StepUploadFiles from "./steps/StepUploadFiles.svelte";
let { data, form } = $props();
let event = $state({});
let participants = $state([]);
let email = $state({'body': '', 'subject': ''});
let authorized = $state(false);
$effect(() => {
if (form && form.new_event) {
event = form.new_event;
}
if (form && form.participants) {
participants = form.participants;
}
});
// Array of step components in order
const steps = [
StepConnectGoogle,
StepCreateEvent,
StepUploadFiles,
StepCraftEmail,
StepOverview
];
let step: number = $state(0);
// let stepConditions = $derived([
// authorized,
// !!new_event?.name,
// !!participants?.length,
// !!subject && !!body
// ]);
// for debugging purpouses
let stepConditions = [
true,
true,
true,
true
];
function nextStep() {
if (step < steps.length - 1) step += 1;
}
function prevStep() {
if (step > 0) step -= 1;
}
</script>
<div class="flex items-center justify-between mb-4 mt-2">
<button
onclick={prevStep}
disabled={step === 0}
class="min-w-[100px] py-2 px-4 bg-white border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
Previous
</button>
<span class="flex-1 text-center text-gray-600 font-medium">
Step {step + 1} of {steps.length}
</span>
<button
onclick={nextStep}
disabled={step === steps.length - 1 || !stepConditions[step]}
class="min-w-[100px] py-2 px-4 bg-white border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
Next
</button>
</div>
{#if step == 0}
<StepConnectGoogle bind:authorized />
{:else if step == 1}
<StepCreateEvent {event} />
{:else if step == 2}
<StepUploadFiles {participants} />
{:else if step == 3}
<StepCraftEmail bind:email />
{:else if step == 4}
<StepOverview
{data}
{event}
{participants}
{email}
{stepConditions}
/>
{/if}

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
let to = '';
let subject = '';
let body = '';
let qrcode_b64 = '';
let loading = false;
let error = '';
let success = '';
let authorized = false;
let refreshToken = '';
async function validateToken(token: string): Promise<boolean> {
if (!token) return false;
const res = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'validate', refreshToken: token })
});
if (!res.ok) return false;
const data = await res.json();
return !!data.valid;
}
onMount(async () => {
refreshToken = localStorage.getItem('gmail_refresh_token') ?? '';
authorized = await validateToken(refreshToken);
});
const connect = () => goto('/private/api/gmail?action=auth');
async function sendTestEmail() {
error = '';
success = '';
loading = true;
try {
const r = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'send',
to,
subject,
text: body,
qr_code: qrcode_b64,
refreshToken
})
});
if (r.ok) {
success = 'Email sent!';
to = subject = body = qrcode_b64 = '';
} else {
error = await r.text();
}
} catch (e) {
error = e.message || 'Unknown error';
}
loading = false;
}
</script>
<div class="max-w-lg mx-auto bg-white border border-gray-300 rounded p-8 mt-8 shadow">
<h2 class="text-2xl font-semibold mb-6 text-center">Test Email Sender</h2>
{#if !authorized}
<div class="mb-4 flex items-center justify-between">
<p class="text-gray-700">Google not connected.</p>
<button class="btn bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded ml-auto" on:click={connect}>
Connect Google
</button>
</div>
{:else}
<form on:submit|preventDefault={sendTestEmail} class="space-y-4">
<label class="block">
<span class="text-gray-700">To</span>
<input type="email" class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200" bind:value={to} required />
</label>
<label class="block">
<span class="text-gray-700">Subject</span>
<input type="text" class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200" bind:value={subject} required />
</label>
<label class="block">
<span class="text-gray-700">Body</span>
<textarea class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200 resize-none" rows="6" bind:value={body} required></textarea>
</label>
<label class="block">
<span class="text-gray-700">QR Code (base64, data:image/png;base64,...)</span>
<input type="text" class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200 font-mono text-xs" bind:value={qrcode_b64} placeholder="Paste base64 image string here" required />
</label>
<button type="submit" class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition" disabled={loading}>
{#if loading}
<svg class="animate-spin h-5 w-5 mr-2 inline-block text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
Sending...
{:else}
Send Test Email
{/if}
</button>
</form>
{/if}
{#if error}
<div class="rounded border-l-4 border-red-500 bg-red-100 p-4 text-red-700 mt-4">{error}</div>
{/if}
{#if success}
<div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700 mt-4">{success}</div>
{/if}
</div>

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import QRCode from 'qrcode';
let { data } = $props();
let session_storage_id = page.url.searchParams.get('data');
let all_data = {};
const StepStatus = {
Loading: 'loading',
Waiting: 'waiting',
Success: 'success',
Failure: 'failure'
} as const;
type StepStatus = (typeof StepStatus)[keyof typeof StepStatus];
let supabase_status: StepStatus = $state(StepStatus.Waiting);
let email_status: StepStatus = $state(StepStatus.Waiting);
onMount(async () => {
if (!session_storage_id) {
console.error('No session storage ID provided in the URL');
return;
}
all_data = JSON.parse(sessionStorage.getItem(session_storage_id) || '{}');
supabase_status = StepStatus.Loading;
try {
const { result } = await insert_data_supabase(all_data.participants, all_data.event);
supabase_status = StepStatus.Success;
// Now send emails
email_status = StepStatus.Loading;
let allSuccess = true;
for (const obj of result) {
let qr_code = await dataToBase64(obj.id);
const payload = {
action: 'send',
to: obj.email,
subject: all_data.email.subject,
text: all_data.email.body,
qr_code: qr_code,
refreshToken: localStorage.getItem('gmail_refresh_token')
};
const res = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
allSuccess = false;
console.error('Failed to send email to', obj.email, await res.text());
}
}
email_status = allSuccess ? StepStatus.Success : StepStatus.Failure;
} catch (e) {
supabase_status = StepStatus.Failure;
email_status = StepStatus.Failure;
console.error(e);
}
});
async function dataToBase64(data: string): Promise<string> {
try {
const url = await QRCode.toDataURL(data);
const parts = url.split(',');
const base64 = parts[1];
return base64;
} catch (err) {
console.error(err);
return '';
}
}
async function insert_data_supabase(participants, event) {
const names = participants.map((p) => p.name);
const surnames = participants.map((p) => p.surname);
const emails = participants.map((p) => p.email);
const {
data: { user },
error: authError
} = await data.supabase.auth.getUser();
const { data: user_profile, error: profileError } = await data.supabase
.from('profiles')
.select('*, section:sections (id, name)')
.eq('id', user?.id)
.single();
const { data: result, error: qrCodeError } = await data.supabase.rpc('create_qrcodes_bulk', {
p_section_id: user_profile?.section.id,
p_event_id: event.id,
p_names: names,
p_surnames: surnames,
p_emails: emails
});
return { result };
}
</script>
<!-- Creating Database Entries -->
<div class="mb-4 rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Creating database entries</h2>
{#if supabase_status === StepStatus.Waiting}
<span class="text-black-600">Waiting...</span>
{:else if supabase_status === StepStatus.Loading}
<span class="text-black-600">Creating entries...</span>
{:else if supabase_status === StepStatus.Success}
<span class="text-green-600">Database entries created successfully.</span>
{:else if supabase_status === StepStatus.Failure}
<span class="text-red-600">Failed to create database entries.</span>
{/if}
</div>
<!-- Sending Emails -->
<div class="rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Sending emails</h2>
{#if email_status === StepStatus.Waiting}
<span class="text-black-600">Waiting...</span>
{:else if email_status === StepStatus.Loading}
<span class="text-black-600">Sending emails...</span>
{:else if email_status === StepStatus.Success}
<span class="text-green-600">Emails sent successfully.</span>
{:else if email_status === StepStatus.Failure}
<span class="text-red-600">Failed to send emails.</span>
{/if}
</div>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
export let authorized = false;
let refreshToken = '';
let loading = true;
let to = '';
let subject = '';
let body = '';
async function validateToken(token: string): Promise<boolean> {
if (!token) return false;
const res = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'validate', refreshToken: token })
});
if (!res.ok) return false;
const data = await res.json();
return !!data.valid;
}
onMount(async () => {
refreshToken = localStorage.getItem('gmail_refresh_token') ?? '';
loading = true;
authorized = await validateToken(refreshToken);
loading = false;
});
/* ⇢ redirects straight to Google via server 302 */
const connect = () => goto('/private/api/gmail?action=auth');
async function disconnect() {
if (!confirm('Disconnect Google account?')) return;
await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'revoke', refreshToken })
});
localStorage.removeItem('gmail_refresh_token');
refreshToken = '';
authorized = false;
}
</script>
<div class="mb-4 rounded border border-gray-300 bg-white p-4">
{#if loading}
<div class="flex items-center space-x-2">
<svg class="animate-spin h-5 w-5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
<span>Checking Google connection...</span>
</div>
{:else}
{#if !authorized}
<section class="flex items-center justify-between w-full">
<p class="mr-4">You havent connected your Google account yet.</p>
<button class="btn bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded ml-auto" on:click={connect}>
Connect Google
</button>
</section>
{:else}
<div class="flex items-center space-x-2 text-green-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>Your connection to Google is good, proceed to next step</span>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
export let email: { subject: string, body: string } = { subject: '', body: '' };
</script>
<form class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none">
<h2 class="text-2xl font-semibold text-center mb-4">Craft Email</h2>
<label class="flex flex-col text-gray-700">
Subject
<input
type="text"
bind:value={email.subject}
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200"
required
/>
</label>
<label class="flex flex-col text-gray-700">
Body
<textarea
bind:value={email.body}
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200 resize-none"
rows="6"
required
></textarea>
</label>
</form>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { event } = $props();
let loading = $state(false);
function handleEnhance() {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}
</script>
<form method="POST" action="?/create" use:enhance={handleEnhance} class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none">
<h2 class="text-2xl font-semibold text-center mb-4">Create Event</h2>
<label class="flex flex-col text-gray-700">
Name
<input
type="text"
name="name"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200"
required
/>
</label>
<label class="flex flex-col text-gray-700">
Date
<input
type="date"
name="date"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200"
required
/>
</label>
<label class="flex flex-col text-gray-700">
Description
<textarea
name="description"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200 resize-none"
rows="3"
required
></textarea>
</label>
<button
type="submit"
class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
>
Submit
</button>
</form>
{#if Object.keys(event).length === 0}
<div class="mt-4 rounded border-l-4 border-gray-500 bg-gray-100 p-4 text-gray-700">
{#if loading}
<strong>Loading...</strong>
{:else}
<strong>No event created yet...</strong>
{/if}
</div>
{:else}
<div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700 mt-4">
<ol>
<li><strong>{event.name}</strong></li>
<li>{event.date}</li>
<li>{event.description}</li>
</ol>
</div>
{/if}

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import QRCode from 'qrcode';
const StepState = {
Waiting: 'waiting',
Processing: 'processing',
FinishedSuccess: 'finished_success',
FinishedFail: 'finished_fail'
};
let qr_codes_state = $state(StepState.Processing);
let emails_state = $state(StepState.FinishedSuccess);
// Inserts all participants into the database and returns their assigned IDs.
async function insert_data_supabase(data, participants, new_event) {
const names = participants.map((p) => p.name);
const surnames = participants.map((p) => p.surname);
const emails = participants.map((p) => p.email);
const {
data: { user },
error: authError
} = await data.supabase.auth.getUser();
const { data: user_profile, error: profileError } = await data.supabase
.from('profiles')
.select('*, section:sections (id, name)')
.eq('id', user?.id)
.single();
const { data: result, error: qrCodeError } = await data.supabase.rpc('create_qrcodes_bulk', {
p_section_id: user_profile?.section.id,
p_event_id: new_event.id,
p_names: names,
p_surnames: surnames,
p_emails: emails
});
return { result };
}
// Creates a base64 interpretation of the ticket ID
function createB64QRCode(data) {
QRCode.toDataURL('I am a pony!')
.then((url) => {
const parts = url.split(',');
return { base64data: parts[1] };
})
.catch((err) => {
console.error(err);
});
}
function sendEmail(email, subject, body, qr_code_base64) {
// Here you would implement the logic to send the email.
// This is a placeholder function.
console.log(`Sending email to ${email} with subject "${subject}" and body "${body}"`);
console.log(`QR Code Base64: ${qr_code_base64}`);
}
</script>
Pl

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { goto } from '$app/navigation';
let { data, event, participants, email, stepConditions } = $props();
function redirectToFinish() {
// Generate a random variable name
const varName = 'event_' + Math.random().toString(36).substr(2, 9);
// Save the data to sessionStorage
sessionStorage.setItem(
varName,
JSON.stringify({ event, participants, email })
);
// Redirect with the variable name as a query parameter
goto(`/private/creator/finish?data=${encodeURIComponent(varName)}`);
}
</script>
<!-- New Event Overview -->
<div class="mb-4 rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Event Overview</h2>
<ul class="space-y-1">
<li><span class="font-semibold">Name:</span> {event.name}</li>
<li><span class="font-semibold">Date:</span> {event.date}</li>
<li><span class="font-semibold">Description:</span> {event.description}</li>
</ul>
</div>
<!-- Email Overview -->
<div class="mb-4 rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Email Preview</h2>
<div class="mb-2"><span class="font-semibold">Subject:</span> {email.subject}</div>
<div class="rounded border bg-gray-50 p-2 whitespace-pre-line text-gray-700">
<span class="font-semibold"></span>
<div>{email.body}</div>
</div>
</div>
<!-- Participants Overview -->
<div class="rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Participants ({participants.length})</h2>
<ul class="space-y-1">
{#each participants.slice(0, 10) as p}
<li class="flex items-center gap-2 border-b pb-1 last:border-b-0">
<span class="font-semibold">{p.name} {p.surname}</span>
<span class="flex-1"></span>
<span class="text-right font-mono text-xs text-gray-600">{p.email}</span>
</li>
{/each}
</ul>
<p class="mt-2 text-sm text-gray-500">Note: Only the first 10 participants are shown.</p>
</div>
<button
onclick={redirectToFinish}
class="mt-4 w-full rounded bg-blue-600 px-4 py-3 font-bold text-white
transition-colors duration-200 hover:bg-blue-700
disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500"
disabled={!stepConditions.every(Boolean)}
>
Generate QR codes and send
</button>
<div class="mt-2 space-y-1">
{#if !stepConditions[0]}
<p class="text-sm text-red-500">Please provide an event name before proceeding.</p>
{/if}
{#if !stepConditions[1]}
<p class="text-sm text-red-500">Please add at least one participant before proceeding.</p>
{/if}
{#if !stepConditions[2]}
<p class="text-sm text-red-500">Please provide an email subject before proceeding.</p>
{/if}
{#if !stepConditions[3]}
<p class="text-sm text-red-500">Please provide an email body before proceeding.</p>
{/if}
</div>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { participants = [] } = $props();
let loading = $state(false);
function handleEnhance() {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}
</script>
<form
method="POST"
action="?/participants"
use:enhance={handleEnhance}
enctype="multipart/form-data"
class="flex w-full flex-col space-y-4 rounded border border-gray-300 bg-white p-8 shadow-none"
>
<h2 class="mb-4 text-center text-2xl font-semibold">Upload Participants</h2>
<label class="flex flex-col text-gray-700">
CSV File
<input
type="file"
name="participants"
id="participants"
accept=".csv"
class="mt-1 rounded border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-200 focus:outline-none"
required
/>
</label>
<button
type="submit"
class="w-full rounded bg-blue-600 py-2 text-white transition hover:bg-blue-700"
>
Submit
</button>
</form>
{#if participants.length === 0}
<div class="mt-4 rounded border-l-4 border-gray-500 bg-gray-100 p-4 text-gray-700">
{#if loading}
<strong>Loading...</strong>
{:else}
<strong>No participants yet...</strong>
{/if}
</div>
{:else}
<div class="mt-4 rounded border-l-4 border-green-500 bg-green-50 p-4 text-green-700">
<ul class="space-y-2">
{#each participants as p, i}
<li class="flex items-center justify-between border-b pb-1">
<div>
<div class="font-semibold">{p.name} {p.surname}</div>
<div class="font-mono text-xs text-gray-600">{p.email}</div>
</div>
</li>
{/each}
</ul>
</div>
{/if}

View File

@@ -0,0 +1,7 @@
export async function load({ locals }) {
const { data: events, error } = await locals.supabase
.from('events')
.select('*')
.order('date', { ascending: false });
return { events };
}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
export let data;
</script>
<h1 class="text-2xl font-bold mb-4 mt-2 text-center">All Events</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
{#each data.events as event}
<a
href={`/private/events/event?id=${event.id}`}
class="block border border-gray-300 rounded bg-white p-4 shadow-none transition cursor-pointer hover:border-blue-500 group"
>
<div class="flex flex-col gap-1">
<span class="font-semibold text-lg text-black-700 group-hover:underline">{event.name}</span>
<span class="text-gray-500 text-sm">{event.date}</span>
</div>
</a>
{/each}
</div>
<a
href="/private/creator"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-full shadow-none border border-gray-300"
>
New Event
</a>

View File

@@ -0,0 +1,13 @@
export async function load({ locals, url }) {
const event_id = url.searchParams.get('id');
const { data: event_data, error: eventError } = await locals.supabase
.from('events')
.select('*')
.eq('id', event_id)
.single()
const { data: participants, error: participantsError } = await locals.supabase
.from('participants')
.select('*, scanned_by:profiles (id, display_name)')
.eq('event', event_id)
return {event_data, participants};
}

View File

@@ -0,0 +1,95 @@
<script lang="ts">
let { data } = $props();
const scannedCount = data.participants.filter((p) => p.scanned).length;
const notScannedCount = data.participants.length - scannedCount;
</script>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">Event Overview</h1>
<div class="mb-2 rounded border border-gray-300 bg-white p-4">
<div class="flex flex-col gap-1">
<span class="text-black-700 text-lg font-semibold">{data.event_data.name}</span>
<span class="text-black-500 text-sm">{data.event_data.date}</span>
</div>
</div>
<div class="mb-2 flex items-center rounded border border-gray-300 bg-white p-4">
<div class="flex flex-1 items-center justify-center gap-2">
<svg
class="inline h-4 w-4 text-green-600"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span class="text-sm text-gray-700">Scanned ({scannedCount})</span>
</div>
<div class="mx-4 h-8 w-px bg-gray-300"></div>
<div class="flex flex-1 items-center justify-center gap-2">
<svg
class="inline h-4 w-4 text-red-600"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" />
<line x1="8" y1="8" x2="16" y2="16" stroke="currentColor" stroke-width="2" />
<line x1="16" y1="8" x2="8" y2="16" stroke="currentColor" stroke-width="2" />
</svg>
<span class="text-sm text-gray-700">Not scanned ({notScannedCount})</span>
</div>
</div>
<div class="rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 rounded text-xl font-bold">Participants ({data.participants.length})</h2>
<ul class="space-y-1">
{#each data.participants as p}
<li class="flex items-center gap-2 border-b pb-1 last:border-b-0">
{#if p.scanned}
<svg
title="Scanned"
class="mr-2 inline h-4 w-4 text-green-600"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{:else}
<svg
title="Not scanned"
class="mr-2 inline h-4 w-4 text-red-600"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" />
<line x1="8" y1="8" x2="16" y2="16" stroke="currentColor" stroke-width="2" />
<line x1="16" y1="8" x2="8" y2="16" stroke="currentColor" stroke-width="2" />
</svg>
{/if}
<span class="font-semibold">{p.name} {p.surname}</span>
<span class="flex-1"></span>
{#if p.scanned_by}
<div class="flex flex-row items-end ml-2">
<span class="mr-1 text-xs text-gray-500">
{new Date(p.scanned_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false
})}
</span>
<span class="text-xs text-gray-500">by {p.scanned_by.display_name}</span>
</div>
{/if}
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,22 @@
// src/routes/my-page/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
// get the logged-in user
const { data: { user }, error: authError } = await locals.supabase.auth.getUser();
const { data: user_profile, error: profileError } = await locals.supabase.from('profiles').select('*, section:sections (id, name)').eq('id', user?.id).single();
if (authError) {
console.error('Supabase auth error:', authError);
throw new Error('Could not get user');
}
if (profileError) {
console.error('Supabase profile error:', profileError);
throw new Error('Could not get user profile');
}
return { user, user_profile };
};

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import type { User } from '@supabase/supabase-js';
export let data: {
user: User | null,
user_profile: any | null
};
</script>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">User Profile</h1>
<div class="mb-4 rounded border border-gray-300 bg-white p-6">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3 mb-4">
<div class="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-600">
{data.user?.user_metadata.display_name?.[0] ?? "U"}
</div>
<div>
<span class="text-lg font-semibold text-gray-800">{data.user?.user_metadata.display_name}</span>
<div class="text-sm text-gray-500">{data.user?.email}</div>
</div>
</div>
<div class="flex flex-col gap-1">
<div>
<span class="font-medium text-gray-700">Section:</span>
<span class="text-gray-900">{data.user_profile?.section.name ?? "N/A"}</span>
</div>
<div>
<span class="font-medium text-gray-700">Position:</span>
<span class="text-gray-900">{data.user_profile?.section_position ?? "N/A"}</span>
</div>
</div>
<h2 class="text-lg mb-2 mt-4">User guide</h2>
<p class="text-gray-700 text-sm leading-relaxed">
To scan a QR code, head over to Scanner in the top right corner. Click on Start scanning and allow camera permissions.
If you close and open your browser and your camera is stuck, simply refresh the page or click Stop scanning and then Start scanning again.
When you scan a QR code, a request is sent to the server to get the user's personal information and to mark their tickets as scanned.
</p>
<h2 class="text-lg mb-2 mt-4">Administrator guide</h2>
<p class="text-gray-700 text-sm leading-relaxed">
You can view events
</p>
</div>
</div>
<a
href="/auth/signout"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-red-500 hover:bg-red-600 text-white font-semibold py-3 px-8 rounded-full shadow-none border border-gray-300 transition"
>
Sign out
</a>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import QRScanner from './QRScanner.svelte';
import TicketDisplay from './TicketDisplay.svelte';
import type { TicketData } from '$lib/types';
import { ScanState, defaultTicketData } from '$lib/types';
let { data } = $props();
let scanned_id = $state<string>("");
let ticket_data = $state<TicketData>(defaultTicketData);
let scan_state = $state<ScanState>(ScanState.scanning);
$effect(() => {
if (scanned_id === "") return;
scan_state = ScanState.scanning;
data.supabase
.from('participants')
.select(`*, event ( id, name ), scanned_by ( id, display_name )`)
.eq('id', scanned_id)
.then( response => {
if (response.data && response.data.length > 0) {
ticket_data = response.data[0];
scan_state = ScanState.scan_successful;
data.supabase.rpc('scan_ticket', { _ticket_id: scanned_id}).then();
} else {
ticket_data = defaultTicketData;
scan_state = ScanState.scan_failed;
}
})
});
</script>
<QRScanner bind:message={scanned_id} />
<TicketDisplay {ticket_data} {scan_state}/>

View File

@@ -1,11 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import {
Html5QrcodeScanner,
type Html5QrcodeResult,
Html5QrcodeScanType,
Html5QrcodeSupportedFormats,
Html5QrcodeScannerState,
Html5QrcodeSupportedFormats
} from 'html5-qrcode';
let width: number = 300;
@@ -38,9 +37,15 @@
);
scanner.render(onScanSuccess, onScanFailure);
});
onDestroy(() => {
if (scanner) {
scanner.clear().catch(() => {});
}
});
</script>
<div id="qr-scanner" class="w-full max-w-sm bg-slate-700 rounded-lg overflow-hidden"></div>
<div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded-sm"></div>
<style>
/* Hide unwanted icons */
@@ -49,6 +54,10 @@
display: none;
}
#qr-scanner {
color: black !important;
}
/* Change camera permission button text */
#qr-scanner :global(#html5-qrcode-button-camera-permission) {
visibility: hidden;
@@ -61,4 +70,9 @@
visibility: visible;
padding: 10px 0;
}
#qr-scanner :global(#qr-scanner__scan_region) {
min-height: auto !important;
aspect-ratio: 1 !important;
}
</style>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import type { TicketData } from '$lib/types';
import { ScanState } from '$lib/types';
let { ticket_data, scan_state }: { ticket_data: TicketData; scan_state: ScanState } = $props();
function formatScannedAt(dateString: string): string {
const date = new Date(dateString);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day}.${month}. ${hours}:${minutes}`;
}
</script>
<div class="py-3">
{#if scan_state === ScanState.scanning}
<div class="rounded border-l-4 border-orange-500 bg-orange-100 p-4 text-orange-700">
<p>Waiting for data...</p>
</div>
{:else if scan_state === ScanState.scan_failed}
<div class="rounded border-l-4 border-red-500 bg-red-100 p-4 text-red-700">
<p><strong>Scan failed!</strong></p>
<p>This is either not a valid ticket or this ticket has been purchased from a different section.</p>
</div>
{:else if scan_state === ScanState.scan_successful}
{#if ticket_data.scanned}
<div class="rounded border-l-4 border-red-500 bg-red-100 p-4 text-red-700">
<p>Ticket already scanned!</p>
<p>
By {ticket_data.scanned_by?.display_name} on
{formatScannedAt(ticket_data.scanned_at)}
</p>
<hr class="my-2 border-t border-red-300" />
<ol>
<li><strong>{ticket_data.event.name}</strong></li>
<li>{ticket_data.name} {ticket_data.surname}</li>
</ol>
</div>
{:else}
<div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700">
<ol>
<li><strong>{ticket_data.event.name}</strong></li>
<li>{ticket_data.name} {ticket_data.surname}</li>
</ol>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1 @@
<p>setup</p>

BIN
static/qr-code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,7 +1,8 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import devtoolsJson from 'vite-plugin-devtools-json';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
plugins: [tailwindcss(), sveltekit(), devtoolsJson()]
});