Compare commits

95 Commits

Author SHA1 Message Date
Roman Krček
48e2944eba Event view impovements 2025-07-12 15:03:23 +02:00
Roman Krček
d945209465 Split event view into components 2025-07-12 14:40:46 +02:00
Roman Krček
d6eee9c498 Better email template display 2025-07-12 14:21:18 +02:00
Roman Krček
0e5d39b149 Fixed problems with changing height 2025-07-12 14:10:50 +02:00
Roman Krček
8247cd33a6 Got rid of more old APIs 2025-07-12 14:10:33 +02:00
Roman Krček
b7483e7ff0 Get rid of old APIs 2025-07-12 13:49:31 +02:00
Roman Krček
f2edd1a5e4 More supabase migrations 2025-07-08 17:26:55 +02:00
Roman Krček
88492e4992 More styling 2025-07-08 16:59:20 +02:00
Roman Krček
608ab81b23 Fixes for smaller devices 2025-07-08 16:40:04 +02:00
Roman Krček
af22543ec8 Fix QR code generation, new scanner styling and ability to choose events. 2025-07-08 16:35:27 +02:00
Roman Krček
6f563bbf7e All event overview improvements 2025-07-08 15:56:01 +02:00
Roman Krček
5bd642b947 Implemented sync functionality with sheets and email sending 2025-07-08 15:30:37 +02:00
Roman Krček
39bd172798 Fixed warnings from svelte about mutability 2025-07-08 13:24:17 +02:00
Roman Krček
4d71bf5410 Added search to sheets 2025-07-08 13:07:24 +02:00
Roman Krček
bd7e3f9720 Fixed basic usability of sheets 2025-07-08 12:54:38 +02:00
Roman Krček
c248e4e074 Fixed google login 2025-07-08 12:37:45 +02:00
Roman Krček
ed317feae7 Restructure progress 2025-07-08 12:07:43 +02:00
Roman Krček
635f507e23 Move fetching of events to browser 2025-07-03 00:17:54 +02:00
Roman Krček
476bb20fa4 Remove leftovers 2025-07-03 00:04:58 +02:00
Roman Krček
6debb8a7ce Security fixes to lib files 2025-07-03 00:02:35 +02:00
Roman Krček
81e2e53cc5 lib sources restructuring 2025-07-02 23:56:11 +02:00
Roman Krček
878198fabd API reformatting 2025-07-02 23:33:35 +02:00
Roman Krček
c2949e4bfe Create event creation structuring and polishing 2025-07-02 23:23:15 +02:00
Roman Krček
822f1a7342 First stage of the new flow 2025-07-02 21:50:45 +02:00
Roman Krček
5fd647d894 Out with the old flow 2025-07-02 21:04:45 +02:00
Roman Krček
3d58500997 Supabase local development setup 2025-07-01 16:55:51 +02:00
Roman Krček
095936dcfd Fixed non-uniform spacing 2025-06-29 17:31:26 +02:00
Roman Krček
26d6f77b43 Use svelte way to retrieve params 2025-06-29 17:31:06 +02:00
Roman Krček
a65cc90ae4 Fix incorrect retrieval of event ID 2025-06-29 17:30:44 +02:00
Roman Krček
35da8d5b34 Minor ux improvements 2025-06-29 17:15:51 +02:00
Roman Krček
1508b501af Add the ability to add participants to already created events 2025-06-29 17:12:36 +02:00
Roman Krček
c7275b7ae8 Added support for archived past events 2025-06-29 16:38:37 +02:00
Roman Krček
1e8d5941ed Add warning to users 2025-06-29 16:01:54 +02:00
Roman Krček
61018b2326 Remove leftover debugging code from service-worker 2025-06-29 15:06:58 +02:00
Roman Krček
cf854f1242 Better previewes for creator 2025-06-29 15:06:30 +02:00
Roman Krček
5e3804edbc Add service worker 2025-06-29 14:40:04 +02:00
Roman Krček
48cfe901a0 Fix terminology on the final page 2025-06-28 00:52:14 +02:00
Roman Krček
e23955f326 Remove leftover debugging code 2025-06-28 00:52:01 +02:00
Roman Krček
ae9cedf51c Fix build issues 2025-06-28 00:40:14 +02:00
Roman Krček
83a2985a46 Fix mixing old and new syntaxes error 2025-06-28 00:35:47 +02:00
Roman Krček
2d7feea623 Fix deprecated on:click
Some checks failed
Build Docker image / build (push) Failing after 43s
Build Docker image / deploy (push) Has been skipped
Build Docker image / verify (push) Has been skipped
2025-06-28 00:33:44 +02:00
Roman Krček
a7262f9815 UX improvements for the final step. 2025-06-28 00:32:51 +02:00
Roman Krček
10badafb63 Fix linter complaints 2025-06-27 23:31:37 +02:00
Roman Krček
9fb76cbc8b Move data processing to the browser for better responsivness and privacy 2025-06-27 23:29:08 +02:00
Roman Krček
9aa5b66b54 Initial github copilot instructions 2025-06-27 22:40:07 +02:00
Roman Krček
fe688de59c Move creator into events structure 2025-06-27 22:39:54 +02:00
Roman Krček
4d8e65f280 Responsivness improvements 2025-06-27 22:36:45 +02:00
Roman Krček
e856ed0304 ScanWave email branding 2025-06-26 14:28:43 +02:00
Roman Krček
c635955240 Merge branch 'supabase'
All checks were successful
Build Docker image / build (push) Successful in 1m32s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 19s
2025-06-25 15:13:01 +02:00
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
ee6bfbe34c Merge pull request 'merge new features' (#9) from supabase into main
All checks were successful
Build Docker image / build (push) Successful in 2m57s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 1m3s
Reviewed-on: erman/esn-code-scanner-app#9
2025-06-25 14:17:09 +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
5ea15fa75c Merge pull request 'UX improvements' (#8) from supabase into main
All checks were successful
Build Docker image / build (push) Successful in 1m18s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 23s
Reviewed-on: erman/esn-code-scanner-app#8
2025-06-24 19:15:46 +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
621d2bff2d Merge pull request 'Styling of creator progress' (#7) from supabase into main
All checks were successful
Build Docker image / build (push) Successful in 59s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 23s
Reviewed-on: erman/esn-code-scanner-app#7
2025-06-24 12:45:39 +02:00
Roman Krček
a42d102c4d Styling of creator progress 2025-06-24 12:45:05 +02:00
f161aa0a3a Merge pull request 'supabase' (#6) from supabase into main
All checks were successful
Build Docker image / build (push) Successful in 1m17s
Build Docker image / deploy (push) Successful in 12s
Build Docker image / verify (push) Successful in 31s
Reviewed-on: erman/esn-code-scanner-app#6
2025-06-24 11:29:07 +02:00
617c00e8dc Add deploy and verify
All checks were successful
Build Docker image / build (push) Successful in 2m23s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 37s
2025-06-24 11:25:40 +02:00
2ca7dc72cb Testing komodo CI
Some checks failed
Build Docker image / build (push) Failing after 5s
2025-06-24 11:15:15 +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
d8d2269817 Merge pull request 'Fix google env variables' (#5) from supabase into main
All checks were successful
Build Docker image / build (push) Successful in 1m25s
Reviewed-on: erman/esn-code-scanner-app#5
2025-06-22 17:59:20 +02:00
Roman Krček
2f41e5ba1a Fix google env variables 2025-06-22 17:58:54 +02:00
f768ae8d8b Fix double runs
All checks were successful
Build Docker image / build (push) Successful in 1m28s
2025-06-22 17:52:57 +02:00
e2a5fe2190 Merge pull request 'Add mailing prototype' (#4) from supabase into main
Some checks failed
Build Docker image / build (push) Has been cancelled
Reviewed-on: erman/esn-code-scanner-app#4
2025-06-22 17:48:47 +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
1ffe7d862f Merge pull request 'Styling' (#3) from supabase into main
All checks were successful
Build Docker image / build (push) Successful in 1m49s
Reviewed-on: erman/esn-code-scanner-app#3
2025-06-21 23:36:53 +02:00
fb9a6677e1 Run only on merges
Some checks failed
Build Docker image / build (push) Has been cancelled
2025-06-21 23:36:20 +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
aba3369565 Merge pull request 'Fix missing env variable during build time' (#2) from supabase into main
All checks were successful
Build Docker image / build (push) Successful in 2m49s
Reviewed-on: erman/esn-code-scanner-app#2
2025-06-21 22:40:33 +02:00
083a7ce2e5 Don't ignore CI changes
Some checks failed
Build Docker image / build (push) Failing after 5m27s
2025-06-21 22:15:54 +02:00
2bd7edde17 Run actions on closed pulls 2025-06-21 22:13:19 +02:00
4dd35c64e0 Merge pull request 'supabase' (#1) from supabase into main
Reviewed-on: erman/esn-code-scanner-app#1
2025-06-21 22:10:08 +02:00
2bf0394ffc Build release update only once a month
Some checks failed
Build Docker image / build (push) Failing after 5s
2025-05-29 11:08:42 +02:00
73 changed files with 6922 additions and 157 deletions

View File

@@ -1,2 +1,7 @@
PUBLIC_SUPABASE_URL=https://abc.supabase.co
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s
# Google OAuth Configuration
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_REDIRECT_URI=http://localhost:5173

View File

@@ -5,7 +5,7 @@ on:
branches:
- main
schedule:
- cron: "0 22 * * 0" # sunday 22:00
- cron: "0 22 1 * *" # First of every month
jobs:
build:
@@ -46,8 +46,28 @@ 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
deploy:
needs: build
steps:
- name: Trigger Komodo Deploy
env:
URL: ${{ secrets.KOMODO_URL }}
SECRET: ${{ secrets.KOMODO_SECRET }}
BODY_FILE: ${{ github.event_path }}
run: |
SIG="sha256=$(openssl dgst -sha256 -hmac "$SECRET" "$BODY_FILE" | cut -d' ' -f2)"
curl -fsSL -X POST "$URL" \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: $SIG" \
-H 'X-GitHub-Event: push' \
-H "X-GitHub-Delivery: $GITHUB_RUN_ID.$GITHUB_RUN_NUMBER" \
--data @"$BODY_FILE"
verify:
needs: build
steps:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.24.0
with:

175
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,175 @@
GitHub Copilot Instructions for This Repository
Basics:
- If you have any questions, always ask me first!
- Use Svelte 5 runes exclusively
- Declare reactive state with $state(); derive values with $derived(); run side-effect logic with $effect() etc.
- When doing client-side loading, always implement placeholders and loaders, so the UI remains responsive and layout shifts are minimized.
- Don't use placeholders and loaders for static data like heading etc.
Do not fall back to the legacy $: label syntax or Svelte 3/4 stores! This is important!
Enforce a clean component structure
<script> comes first, followed by markup and then an optional <style> (rarely neededprefer Tailwind).
Export component props with export let (still valid in Svelte 5).
Keep each component focused on one visual/behavioural concern; split larger UIs into children.
Tailwind-only styling conventions
Base container: rounded-lg border border-gray-300 (or rounded-md on small items).
Absolutely no shadow-* classes.
Use p-4 or p-6 for internal padding, and gap-* utilities (not margin hacks) for spacing between children.
Prefer neutral greys (gray-50gray-800) and a single accent palette defined in tailwind.config.js.
HTML & accessibility
Generate semantic elements (<button>, <nav>, <main>, <section>, <label>, etc.).
Every interactive element must have an accessible name (aria-label, visible text, or title).
Do not generate tabindex gymnastics; rely on natural DOM order.
Type safety & tooling
Default to <script lang="ts"> unless the file is explicitly plain JS.
Always import types from @types/svelte or svelte where needed.
File / folder conventions
Component names are PascalCase.svelte.
Collocate tests as ComponentName.test.ts beside the component.
Put shared util functions in src/lib.
Example pattern (reference only)
svelte
Copy
Edit
<!-- copilot: follow the repo instructions above -->
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => console.log(`count is ${count}`));
</script>
<div class="rounded-lg border border-gray-300 p-4 flex flex-col gap-4">
<button
class="rounded-md px-4 py-2 bg-blue-600 text-white"
onclick={() => count++}
aria-label="Increment counter"
>
{count}
</button>
<p>{doubled}</p>
</div>
What not to do
No inline style="" attributes.
No external CSS files unless Tailwind cannot express the rule.
No class names that imply design debt (.box, .wrapper, .container-1, etc.).
Avoid non-reactive variables; if a value affects the UI, use a rune.
NEVER $: label syntax; use $state(), $derived(), and $effect().
If you want to use supabse client in the browser, it is stored in the data
variable obtained from let { data } = $props();
Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead
onsubmit|preventDefault={handleSubmit} is depracated, do not use it!
Loading session using page.server.ts is not needed as the session is already available in the locals object.
Do not use import { page } from '$app/stores'; as it is deprecated! Use instead: import { page } from '$app/state';
IMPORTANT: Always make sure that the client-side module are not importing secrets
or are running any sensritive code that could expose secrets to the client.
If any requests are needed to check sensitive infomration, create an api route and
fetch data from there instead of directly in the client-side module.
The database schema in supabase is as follows:
-- WARNING: This schema is for context only and is not meant to be run.
-- Table order and constraints may not be valid for execution.
-- WARNING: This schema is for context only and is not meant to be run.
-- Table order and constraints may not be valid for execution.
CREATE TABLE public.events (
id uuid NOT NULL DEFAULT gen_random_uuid(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
created_by uuid DEFAULT auth.uid(),
name text,
date date,
section_id uuid,
email_subject text,
email_body text,
sheet_id text,
name_column numeric,
surname_column numeric,
email_column numeric,
confirmation_column numeric,
CONSTRAINT events_pkey PRIMARY KEY (id),
CONSTRAINT events_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id),
CONSTRAINT events_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id)
);
CREATE TABLE public.events_archived (
id uuid NOT NULL DEFAULT gen_random_uuid(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
date date,
name text NOT NULL,
total_participants numeric,
scanned_participants numeric,
section_id uuid,
CONSTRAINT events_archived_pkey PRIMARY KEY (id),
CONSTRAINT events_archived_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id)
);
CREATE TABLE public.participants (
id uuid NOT NULL DEFAULT gen_random_uuid(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
created_by uuid DEFAULT auth.uid(),
event uuid,
name text,
surname text,
email text,
scanned boolean DEFAULT false,
scanned_at timestamp with time zone,
scanned_by uuid,
section_id uuid,
email_sent boolean DEFAULT false,
CONSTRAINT participants_pkey PRIMARY KEY (id),
CONSTRAINT participants_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id),
CONSTRAINT participants_event_fkey FOREIGN KEY (event) REFERENCES public.events(id),
CONSTRAINT participants_scanned_by_fkey FOREIGN KEY (scanned_by) REFERENCES public.profiles(id),
CONSTRAINT qrcodes_scanned_by_fkey FOREIGN KEY (scanned_by) REFERENCES auth.users(id),
CONSTRAINT qrcodes_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id)
);
CREATE TABLE public.profiles (
id uuid NOT NULL,
display_name text,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
section_id uuid,
section_position USER-DEFINED NOT NULL DEFAULT 'member'::section_posititon,
CONSTRAINT profiles_pkey PRIMARY KEY (id),
CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id),
CONSTRAINT profiles_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id)
);
CREATE TABLE public.sections (
id uuid NOT NULL DEFAULT gen_random_uuid(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
name text NOT NULL UNIQUE,
CONSTRAINT sections_pkey PRIMARY KEY (id)
);

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"

1114
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",
@@ -22,15 +22,21 @@
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"supabase": "^2.30.4",
"svelte": "^5.0.0",
"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": {
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12"
"@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

@@ -0,0 +1,117 @@
<script lang="ts">
import { onMount } from 'svelte';
import { GoogleAuthManager, createGoogleAuthState } from '$lib/google/auth/manager.js';
// Props
let {
onSuccess,
onError,
disabled = false,
size = 'default',
variant = 'primary'
} = $props<{
onSuccess?: (token: string) => void;
onError?: (error: string) => void;
disabled?: boolean;
size?: 'small' | 'default' | 'large';
variant?: 'primary' | 'secondary';
}>();
// State
let authState = $state(createGoogleAuthState());
let authManager = new GoogleAuthManager(authState);
onMount(() => {
authManager.checkConnection();
});
async function handleConnect() {
if (authState.connecting || disabled) return;
try {
await authManager.connectToGoogle();
if (authState.isConnected && authState.token) {
onSuccess?.(authState.token);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to Google';
onError?.(errorMessage);
}
}
async function handleDisconnect() {
await authManager.disconnectGoogle();
}
// Size classes
const sizeClasses = {
small: 'px-3 py-1.5 text-sm',
default: 'px-4 py-2 text-base',
large: 'px-6 py-3 text-lg'
};
// Variant classes
const variantClasses = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white border-transparent',
secondary: 'bg-white hover:bg-gray-50 text-gray-900 border-gray-300'
};
</script>
{#if authState.isConnected}
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 border border-green-300 whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-sm font-medium text-green-800">Connected</span>
</div>
{#if authState.userEmail}
<div class="flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 border border-blue-300 max-w-full overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg>
<span class="text-sm font-medium text-blue-800 truncate">{authState.userEmail}</span>
</div>
{/if}
<button
onclick={handleDisconnect}
class="flex items-center gap-2 rounded-full bg-red-100 px-3 py-1 border border-red-300 hover:bg-red-200 transition-colors whitespace-nowrap"
aria-label="Disconnect from Google"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span class="text-sm font-medium text-red-800">Disconnect</span>
</button>
</div>
{:else}
<div class="flex flex-col gap-2">
<button
onclick={handleConnect}
disabled={authState.connecting || disabled}
class="inline-flex items-center border font-medium rounded-md transition disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 {sizeClasses[size as keyof typeof sizeClasses]} {variantClasses[variant as keyof typeof variantClasses]}"
aria-label="Connect to Google"
>
{#if authState.connecting}
<div class="w-4 h-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
Connecting...
{:else}
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Connect to Google
{/if}
</button>
{#if authState.error}
<div class="text-sm text-red-600 mt-1">
{authState.error}
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,237 @@
import { browser } from '$app/environment';
// Client-side only functions
export const scopes = [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/spreadsheets.readonly'
];
/**
* Initialize Google Auth (placeholder for client-side)
*/
export async function initGoogleAuth(): Promise<void> {
if (!browser) return;
// Google Auth initialization is handled by the OAuth flow
// No initialization needed for our server-side approach
}
/**
* Get the Google Auth URL
* @returns URL for Google OAuth
*/
export function getAuthUrl(): string {
if (!browser) return '';
// This should be obtained from the server
return '/auth/google';
}
/**
* Check if an access token is valid
* @param accessToken - Google access token to validate
* @returns True if the token is valid
*/
export async function isTokenValid(accessToken: string): Promise<boolean> {
if (!browser) return false;
try {
const response = await fetch(`https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${accessToken}`);
const data = await response.json();
if (response.ok && data.expires_in && data.expires_in > 0) {
return true;
}
return false;
} catch (error) {
console.error('Error validating token:', error);
return false;
}
}
/**
* Refresh an access token using the refresh token
* @param refreshToken - Google refresh token
* @returns New access token or null if failed
*/
export async function refreshAccessToken(refreshToken: string): Promise<string | null> {
try {
const response = await fetch('/private/api/google/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken })
});
if (response.ok) {
const data = await response.json();
return data.accessToken;
}
return null;
} catch (error) {
console.error('Error refreshing token:', error);
return null;
}
}
/**
* Get Google user information
* @param accessToken - Google access token
* @returns User info including email, name, and picture
*/
export async function getUserInfo(accessToken: string): Promise<{ email: string; name: string; picture: string } | null> {
try {
const response = await fetch('/private/api/google/auth/userinfo', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error fetching user info:', error);
return null;
}
}
/**
* Authenticate with Google using OAuth popup flow
* @returns Authentication result with success status and tokens
*/
export async function authenticateWithGoogle(): Promise<{
success: boolean;
refreshToken?: string;
userEmail?: string;
error?: string;
}> {
if (!browser) {
return { success: false, error: 'Not in browser environment' };
}
return new Promise((resolve) => {
try {
// Open popup window for OAuth
const popup = window.open(
'/auth/google',
'google-auth',
'width=500,height=600,scrollbars=yes,resizable=yes,left=' +
Math.round(window.screen.width / 2 - 250) + ',top=' +
Math.round(window.screen.height / 2 - 300)
);
if (!popup) {
resolve({ success: false, error: 'Failed to open popup window. Please allow popups for this site.' });
return;
}
let authCompleted = false;
let popupTimer: number | null = null;
// Store current timestamp to detect changes in localStorage
const startTimestamp = localStorage.getItem('google_auth_timestamp') ?? '0';
// Poll localStorage for auth completion
const pollInterval = setInterval(() => {
try {
const currentTimestamp = localStorage.getItem('google_auth_timestamp');
// If timestamp has changed, auth is complete
if (currentTimestamp && currentTimestamp !== startTimestamp) {
handleAuthSuccess();
}
} catch (e) {
console.error('Error checking auth timestamp:', e);
}
}, 500); // Poll every 500ms
// Common handler for authentication success
function handleAuthSuccess() {
if (authCompleted) return; // Prevent duplicate handling
authCompleted = true;
// Clean up timers
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
// Get tokens from localStorage
const refreshToken = localStorage.getItem('google_refresh_token');
const userEmail = localStorage.getItem('google_user_email');
if (refreshToken) {
resolve({
success: true,
refreshToken,
userEmail: userEmail ?? undefined
});
} else {
resolve({ success: false, error: 'No refresh token found after authentication' });
}
}
// Clean up function to handle all cleanup in one place
const cleanUp = () => {
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
};
// Set a timeout for initial auth check
popupTimer = setTimeout(() => {
if (!authCompleted) {
cleanUp();
// Check if tokens were stored by the popup before it was closed
const refreshToken = localStorage.getItem('google_refresh_token');
const userEmail = localStorage.getItem('google_user_email');
if (refreshToken) {
resolve({
success: true,
refreshToken,
userEmail: userEmail ?? undefined
});
} else {
resolve({ success: false, error: 'Authentication timeout or cancelled' });
}
}
}, 30 * 1000) as unknown as number;
// Final cleanup timeout
setTimeout(() => {
if (!authCompleted) {
cleanUp();
resolve({ success: false, error: 'Authentication timeout' });
}
}, 60 * 1000);
} catch (error) {
console.error('Error connecting to Google:', error);
resolve({ success: false, error: error instanceof Error ? error.message : 'Unknown error' });
}
});
}
/**
* Revoke a Google access token
* @param accessToken - Google access token to revoke
* @returns True if revocation was successful
*/
export async function revokeToken(accessToken: string): Promise<boolean> {
try {
const response = await fetch('/private/api/google/auth/revoke', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ accessToken })
});
return response.ok;
} catch (error) {
console.error('Error revoking token:', error);
return false;
}
}

View File

@@ -0,0 +1,120 @@
import { authenticateWithGoogle } from '$lib/google/auth/client.js';
export interface GoogleAuthState {
isConnected: boolean;
checking: boolean;
connecting: boolean;
showCancelOption: boolean;
token: string | null;
error: string | null;
userEmail: string | null;
}
export function createGoogleAuthState(): GoogleAuthState {
return {
isConnected: false,
checking: false,
connecting: false,
showCancelOption: false,
token: null,
error: null,
userEmail: null
};
}
export class GoogleAuthManager {
private readonly state: GoogleAuthState;
private cancelTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(state: GoogleAuthState) {
this.state = state;
}
checkConnection(): void {
this.state.checking = true;
this.state.error = null;
try {
const token = localStorage.getItem('google_refresh_token');
const email = localStorage.getItem('google_user_email');
this.state.isConnected = !!token;
this.state.token = token;
this.state.userEmail = email;
} catch (error) {
console.error('Error checking connection:', error);
this.state.error = 'Failed to check connection status';
} finally {
this.state.checking = false;
}
}
async connectToGoogle(): Promise<void> {
if (this.state.connecting) return;
this.state.connecting = true;
this.state.error = null;
this.state.showCancelOption = false;
// Show cancel option after 5 seconds
this.cancelTimeout = setTimeout(() => {
this.state.showCancelOption = true;
}, 5000);
try {
const result = await authenticateWithGoogle();
if (result.success && result.refreshToken) {
// Store tokens
localStorage.setItem('google_refresh_token', result.refreshToken);
if (result.userEmail) {
localStorage.setItem('google_user_email', result.userEmail);
}
// Update state
this.state.isConnected = true;
this.state.token = result.refreshToken;
this.state.userEmail = result.userEmail;
} else {
throw new Error(result.error ?? 'Authentication failed');
}
} catch (error) {
this.state.error = error instanceof Error ? error.message : 'Failed to connect to Google';
} finally {
this.state.connecting = false;
this.state.showCancelOption = false;
if (this.cancelTimeout) {
clearTimeout(this.cancelTimeout);
this.cancelTimeout = null;
}
}
}
cancelGoogleAuth(): void {
this.state.connecting = false;
this.state.showCancelOption = false;
this.state.error = null;
if (this.cancelTimeout) {
clearTimeout(this.cancelTimeout);
this.cancelTimeout = null;
}
}
async disconnectGoogle(): Promise<void> {
try {
// Clear local storage
localStorage.removeItem('google_refresh_token');
localStorage.removeItem('google_user_email');
// Reset state
this.state.isConnected = false;
this.state.token = null;
this.state.userEmail = null;
this.state.error = null;
} catch (error) {
console.error('Error disconnecting:', error);
this.state.error = 'Failed to disconnect';
}
}
}

View File

@@ -0,0 +1,57 @@
import { google } from 'googleapis';
import { env } from '$env/dynamic/private';
// Define OAuth scopes for the Google APIs we need to access
export const scopes = [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/spreadsheets.readonly'
];
/**
* Create a new OAuth2 client instance
* @returns Google OAuth2 client
*/
export function getOAuthClient() {
return new google.auth.OAuth2(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
env.GOOGLE_REDIRECT_URI
);
}
/**
* Create a authentication URL for OAuth flow
* @returns Auth URL for Google OAuth
*/
export function createAuthUrl() {
return getOAuthClient().generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: scopes,
redirect_uri: env.GOOGLE_REDIRECT_URI
});
}
/**
* Exchange the authorization code for access and refresh tokens
* @param code - Authorization code from OAuth callback
* @returns Access and refresh tokens
*/
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;
}
/**
* Get an authenticated client using a refresh token
* @param refreshToken - Refresh token for authentication
* @returns Authenticated OAuth2 client
*/
export function getAuthenticatedClient(refreshToken: string) {
const oauth = getOAuthClient();
oauth.setCredentials({ refresh_token: refreshToken });
return oauth;
}

13
src/lib/google/client.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Google API integration module
*
* This module provides utilities for interacting with Google APIs:
* - Authentication (server and client-side)
* - Sheets API
*/
// Google service modules
export * as googleAuthClient from './auth/client.ts';
export * as googleSheetsClient from './sheets/client.ts';

View File

@@ -0,0 +1,94 @@
import { google } from 'googleapis';
import quotedPrintable from 'quoted-printable';
import { getOAuthClient } from '../auth/server.js';
/**
* Create an HTML email template with ScanWave branding
* @param text - Email body text
* @returns HTML email template
*/
export function createEmailTemplate(text: string): string {
return `<!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 ScanWave</p>
</div>
</div>
</body>
</html>`;
}
/**
* Send an email through Gmail with QR code
* @param refreshToken - Google refresh token
* @param params - Email parameters (to, subject, text, qr_code)
*/
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 = createEmailTemplate(text);
const boundary = 'BOUNDARY';
const nl = '\r\n'; // RFC-5322 line ending
// Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode
const htmlBuffer = Buffer.from(message_html, 'utf8');
const htmlLatin1 = htmlBuffer.toString('latin1');
const htmlQP = quotedPrintable.encode(htmlLatin1);
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 }
});
}

15
src/lib/google/server.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Google API integration module
*
* This module provides utilities for interacting with Google APIs:
* - Authentication (server and client-side)
* - Sheets API
* - Gmail API
*/
// Google service modules
export * as googleAuthServer from './auth/server.ts';
export * as googleSheetsServer from './sheets/server.ts';
export * as googleGmailServer from './gmail/server.ts';

View File

@@ -0,0 +1,23 @@
// Client-side Sheets functions (use fetch to call protected API endpoints)
/**
* Fetch recent spreadsheets via protected endpoint
*/
export async function getRecentSpreadsheetsClient(refreshToken: string, limit: number = 10) {
const response = await fetch(`/private/api/google/sheets/recent?limit=${limit}`, {
headers: { Authorization: `Bearer ${refreshToken}` }
});
if (!response.ok) throw new Error('Failed to fetch recent sheets');
return await response.json();
}
/**
* Fetch spreadsheet data via protected endpoint
*/
export async function getSpreadsheetDataClient(refreshToken: string, sheetId: string, range: string = 'A1:Z10') {
const response = await fetch(`/private/api/google/sheets/${sheetId}/data?range=${encodeURIComponent(range)}`, {
headers: { Authorization: `Bearer ${refreshToken}` }
});
if (!response.ok) throw new Error('Failed to fetch spreadsheet data');
return await response.json();
}

View File

@@ -0,0 +1,127 @@
import { google } from 'googleapis';
import { getAuthenticatedClient } from '../auth/server.js';
import { GoogleSheet } from './types.ts';
// Type for sheet data
export interface SheetData {
values: string[][];
}
// Server-side Google Sheets API handler
export const googleSheetsServer = {
getRecentSpreadsheets,
getSpreadsheetData,
getSpreadsheetInfo,
searchSheets
};
/**
* Get a list of recent Google Sheets
* @param refreshToken - Google refresh token
* @param limit - Maximum number of sheets to return
* @returns List of Google Sheets
*/
export async function getRecentSpreadsheets(
refreshToken: string,
limit: number = 10
): Promise<GoogleSheet[]> {
const oauth = getAuthenticatedClient(refreshToken);
const drive = google.drive({ version: 'v3', auth: oauth });
const response = await drive.files.list({
q: "mimeType='application/vnd.google-apps.spreadsheet'",
orderBy: 'modifiedTime desc',
pageSize: limit,
fields: 'files(id,name,modifiedTime,webViewLink)'
});
return (
response.data.files?.map(file => ({
id: file.id!, // eslint-disable-line @typescript-eslint/no-non-null-assertion
name: file.name!,
modifiedTime: file.modifiedTime!,
webViewLink: file.webViewLink!
})) || []
);
}
/**
* Get data from a Google Sheet
* @param refreshToken - Google refresh token
* @param spreadsheetId - ID of the spreadsheet
* @param range - Cell range to retrieve (default: A1:Z10)
* @returns Sheet data as a 2D array
*/
export async function getSpreadsheetData(
refreshToken: string,
spreadsheetId: string,
range: string = 'A1:Z10'
): Promise<SheetData> {
const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth });
const response = await sheets.spreadsheets.values.get({
spreadsheetId,
range
});
return {
values: response.data.values || []
};
}
/**
* Get metadata about a Google Sheet
* @param refreshToken - Google refresh token
* @param spreadsheetId - ID of the spreadsheet
* @returns Spreadsheet metadata
*/
export async function getSpreadsheetInfo(
refreshToken: string,
spreadsheetId: string
) {
const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth });
const response = await sheets.spreadsheets.get({
spreadsheetId,
fields: 'properties.title,sheets.properties(title,sheetId)'
});
return response.data;
}
/**
* Search for Google Sheets by name
* @param refreshToken - Google refresh token
* @param query - Search query
* @param limit - Maximum number of sheets to return
* @returns List of Google Sheets matching the query
*/
export async function searchSheets(
refreshToken: string,
query: string,
limit: number = 20
): Promise<GoogleSheet[]> {
const oauth = getAuthenticatedClient(refreshToken);
const drive = google.drive({ version: 'v3', auth: oauth });
// Create a query to search for spreadsheets with names containing the search term
const q = `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`;
const response = await drive.files.list({
q,
orderBy: 'modifiedTime desc',
pageSize: limit,
fields: 'files(id,name,modifiedTime,webViewLink)'
});
return (
response.data.files?.map(file => ({
id: file.id!, // eslint-disable-line @typescript-eslint/no-non-null-assertion
name: file.name!,
modifiedTime: file.modifiedTime!,
webViewLink: file.webViewLink!
})) || []
);
}

View File

@@ -0,0 +1,10 @@
export interface GoogleSheet {
id: string;
name: string;
modifiedTime: string;
webViewLink: string;
}
export interface SheetData {
values: string[][];
}

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

5
src/lib/types/quoted-printable.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module 'quoted-printable' {
export function encode(text: string): string;
export function decode(text: string): string;
export default { encode, decode };
}

View File

@@ -1,7 +1,9 @@
export enum ScanState {
scanning,
scan_successful,
scan_failed
already_scanned,
scan_failed,
wrong_event
}
export type TicketData = {
@@ -22,7 +24,7 @@ export const defaultTicketData: TicketData = {
name: '',
surname: '',
email: '',
event: '',
event: { id: '', name: '' },
created_at: new Date().toISOString(),
created_by: null,
scanned: false,

View File

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

View File

@@ -1,6 +1,13 @@
<script lang="ts">
</script>
<p>hello world</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

@@ -1,2 +1,14 @@
<a href="/auth/login"><button>Login</button></a>
<a href="/auth/singup"><button>Signup</button></a>
<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,8 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createAuthUrl } from '$lib/google/auth/server.js';
export const GET: RequestHandler = () => {
const authUrl = createAuthUrl();
throw redirect(302, authUrl);
};

View File

@@ -0,0 +1,119 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { googleAuthServer } from '$lib/google/server.ts';
export const GET: RequestHandler = async ({ url }) => {
try {
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
console.error('Google OAuth error:', error);
throw redirect(302, '/private/events?error=google_auth_denied');
}
if (!code) {
throw redirect(302, '/private/events?error=missing_auth_code');
}
// Exchange code for tokens
const oauth = googleAuthServer.getOAuthClient();
const { tokens } = await oauth.getToken(code);
if (!tokens.refresh_token || !tokens.access_token) {
throw redirect(302, '/private/events?error=incomplete_tokens');
}
// Get user info to retrieve email
let userEmail = '';
try {
oauth.setCredentials(tokens);
const { google } = await import('googleapis');
const oauth2 = google.oauth2({ version: 'v2', auth: oauth });
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email ?? '';
} catch (emailError) {
console.error('Error fetching user email:', emailError);
// Continue without email - it's not critical for the auth flow
}
// Create a success page with tokens that closes the popup and communicates with parent
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Google Authentication Success</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: #f9fafb;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.success {
color: #059669;
margin-bottom: 1rem;
}
.loading {
color: #6b7280;
}
</style>
</head>
<body>
<div class="container">
<div class="success">✓ Authentication successful!</div>
<div class="loading">Closing window...</div>
</div>
<script>
(function() {
try {
// Store tokens in localStorage (same origin)
localStorage.setItem('google_access_token', '${tokens.access_token}');
localStorage.setItem('google_refresh_token', '${tokens.refresh_token}');
${userEmail ? `localStorage.setItem('google_user_email', '${userEmail}');` : ''}
// Set timestamp that the main application will detect
localStorage.setItem('google_auth_timestamp', Date.now().toString());
// Update UI to show success
document.querySelector('.loading').textContent = 'Authentication complete! This window will close automatically.';
// Close window after a short delay
setTimeout(() => {
try {
window.close();
} catch (e) {
// If we can't close automatically, update message
document.querySelector('.loading').textContent = 'Authentication complete! You can close this window now.';
}
}, 1500);
} catch (error) {
console.error('Error in auth callback:', error);
// Update UI to show error
document.querySelector('.success').textContent = '✗ Authentication error';
document.querySelector('.loading').textContent = 'Please close this window and try again.';
}
})();
</script>
</body>
</html>`;
return new Response(html, {
headers: {
'Content-Type': 'text/html'
}
});
} catch (error) {
console.error('Error handling Google OAuth callback:', error);
throw redirect(302, '/private/events?error=google_auth_failed');
}
};

View File

@@ -1,11 +1,33 @@
<form method="POST" action="?/login">
<label>
Email
<input name="email" type="email" />
</label>
<label>
Password
<input name="password" type="password" />
</label>
<button>Login</button>
</form>
<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

@@ -1,15 +1,43 @@
<form method="POST" action="?/login">
<label>
Email
<input name="email" type="email" />
</label>
<label>
Password
<input name="password" type="password" />
</label>
<label>
Display name
<input name="display_name" type="text" />
</label>
<button formaction="?/signup">Sign up</button>
</form>
<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

@@ -2,14 +2,20 @@
// Add any navbar logic here if needed
</script>
<nav class="bg-gray-800 text-white p-4 flex items-center justify-between">
<div class="font-bold text-lg">ESN Scanner</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>
</ul>
<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 p-2 bg-white ">
<div class="container max-w-2xl mx-auto p-2 bg-white">
<slot />
</div>

View File

@@ -0,0 +1,47 @@
# Google API Integration
This directory contains unified endpoints for Google API integration, all protected under the `/private` route to ensure only authenticated users can access them.
## Auth Endpoints
### `/private/api/google/auth/refresh`
- **Method**: POST
- **Purpose**: Refreshes an access token using a refresh token
- **Body**: `{ "refreshToken": "your-refresh-token" }`
- **Response**: `{ "accessToken": "new-access-token", "expiresIn": 3600 }`
### `/private/api/google/auth/userinfo`
- **Method**: GET
- **Purpose**: Gets information about the authenticated user
- **Headers**: Authorization: Bearer `access_token`
- **Response**: `{ "email": "user@example.com", "name": "User Name", "picture": "profile-pic-url" }`
### `/private/api/google/auth/revoke`
- **Method**: POST
- **Purpose**: Revokes an access token
- **Body**: `{ "accessToken": "token-to-revoke" }`
- **Response**: `{ "success": true }`
## Sheets Endpoints
### `/private/api/google/sheets/recent`
- **Method**: GET
- **Purpose**: Gets a list of recent spreadsheets
- **Headers**: Authorization: Bearer `refresh_token`
- **Response**: Array of spreadsheet objects
### `/private/api/google/sheets/[sheetId]/data`
- **Method**: GET
- **Purpose**: Gets data from a specific spreadsheet
- **Headers**: Authorization: Bearer `refresh_token`
- **URL Parameters**: sheetId - The ID of the spreadsheet
- **Response**: Spreadsheet data including values array
## Client Usage
Use the utility functions in `$lib/google.ts` to interact with these endpoints.

View File

@@ -0,0 +1,30 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { googleAuthServer } from '$lib/google/server.ts';
export const POST: RequestHandler = async ({ request }) => {
try {
const { refreshToken } = await request.json();
if (!refreshToken) {
return json({ error: 'Refresh token is required' }, { status: 400 });
}
const oauth = googleAuthServer.getOAuthClient();
oauth.setCredentials({ refresh_token: refreshToken });
const { credentials } = await oauth.refreshAccessToken();
if (!credentials.access_token) {
return json({ error: 'Failed to refresh token' }, { status: 500 });
}
return json({
accessToken: credentials.access_token,
expiresIn: credentials.expiry_date
});
} catch (error) {
console.error('Error refreshing access token:', error);
return json({ error: 'Failed to refresh access token' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
try {
const { accessToken } = await request.json();
if (!accessToken) {
return json({ error: 'Access token is required' }, { status: 400 });
}
// Call Google's token revocation endpoint
const response = await fetch(`https://accounts.google.com/o/oauth2/revoke?token=${accessToken}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
if (response.ok) {
return json({ success: true });
} else {
const error = await response.text();
console.error('Error revoking token:', error);
return json({ error: 'Failed to revoke token' }, { status: 500 });
}
} catch (error) {
console.error('Error revoking access token:', error);
return json({ error: 'Failed to revoke access token' }, { status: 500 });
}
};

View File

@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { googleAuthServer } from '$lib/google/server.ts';
import { google } from 'googleapis';
export const GET: RequestHandler = async ({ request }) => {
try {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return json({ error: 'Missing or invalid authorization header' }, { status: 401 });
}
const accessToken = authHeader.slice(7);
// Create OAuth client with the token
const oauth = googleAuthServer.getOAuthClient();
oauth.setCredentials({ access_token: accessToken });
// Call the userinfo endpoint to get user details
const oauth2 = google.oauth2({ version: 'v2', auth: oauth });
const userInfo = await oauth2.userinfo.get();
return json({
email: userInfo.data.email,
name: userInfo.data.name,
picture: userInfo.data.picture
});
} catch (error) {
console.error('Error fetching user info:', error);
return json({ error: 'Failed to fetch user info' }, { status: 500 });
}
};

View File

@@ -0,0 +1,154 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sendGmail } from '$lib/google/gmail/server.js';
import QRCode from 'qrcode';
interface Participant {
id: string;
name: string;
surname: string;
email: string;
}
interface EmailResult {
participant: Participant;
success: boolean;
error?: string;
}
async function generateQRCode(participantId: string): Promise<string> {
const qrCodeBase64 = await QRCode.toDataURL(participantId, {
type: 'image/png',
margin: 2,
scale: 8
});
// Remove the data URL prefix to get just the base64 string
return qrCodeBase64.replace(/^data:image\/png;base64,/, '');
}
async function sendEmailToParticipant(
participant: Participant,
subject: string,
text: string,
eventId: string,
refreshToken: string,
supabase: any
): Promise<EmailResult> {
try {
const qrCodeBase64Data = await generateQRCode(participant.id);
// Send email with QR code
await sendGmail(refreshToken, {
to: participant.email,
subject: subject,
text: text,
qr_code: qrCodeBase64Data
});
// Call the participant_emailed RPC function
try {
await supabase.rpc('participant_emailed', {
p_participant_id: participant.id
});
} catch (dbError) {
console.error('Failed to call participant_emailed RPC:', dbError);
// Don't fail the entire operation if the RPC call fails
}
return {
participant: participant,
success: true
};
} catch (error) {
console.error('Failed to send email to participant:', participant.email, error);
return {
participant: participant,
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
function validateRequest(participants: unknown, subject: unknown, text: unknown, eventId: unknown, refreshToken: unknown) {
if (!participants || !Array.isArray(participants)) {
return { error: 'Invalid participants array', status: 400 };
}
if (!subject || !text) {
return { error: 'Subject and text are required', status: 400 };
}
if (!eventId) {
return { error: 'Event ID is required', status: 400 };
}
if (!refreshToken || typeof refreshToken !== 'string') {
return { error: 'Refresh token is required', status: 401 };
}
return null;
}
export const POST: RequestHandler = async ({ request, locals }) => {
try {
const { participants, subject, text, eventId, refreshToken } = await request.json();
const validationError = validateRequest(participants, subject, text, eventId, refreshToken);
if (validationError) {
return json({ error: validationError.error }, { status: validationError.status });
}
const results: EmailResult[] = [];
let successCount = 0;
let errorCount = 0;
// Send emails to each participant
for (const participant of participants as Participant[]) {
const result = await sendEmailToParticipant(
participant,
subject as string,
text as string,
eventId as string,
refreshToken as string,
locals.supabase
);
results.push(result);
if (result.success) {
successCount++;
} else {
errorCount++;
}
}
return json({
success: true,
results,
summary: {
total: participants.length,
success: successCount,
errors: errorCount
}
});
} catch (error) {
console.error('Email sending error:', error);
// Handle specific Gmail API errors
if (error instanceof Error) {
if (error.message.includes('Invalid Credentials') || error.message.includes('unauthorized')) {
return json({ error: 'Invalid or expired Google credentials' }, { status: 401 });
}
if (error.message.includes('quota')) {
return json({ error: 'Gmail API quota exceeded' }, { status: 429 });
}
if (error.message.includes('rate')) {
return json({ error: 'Rate limit exceeded' }, { status: 429 });
}
}
return json({ error: 'Failed to send emails' }, { status: 500 });
}
};

View File

@@ -0,0 +1,22 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { googleSheetsServer } from '$lib/google/sheets/server.js';
export const GET: RequestHandler = async ({ params, request }) => {
try {
const { sheetId } = params;
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return json({ error: 'Missing or invalid authorization header' }, { status: 401 });
}
const refreshToken = authHeader.slice(7);
const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10');
return json(sheetData);
} catch (error) {
console.error('Error fetching spreadsheet data:', error);
return json({ error: 'Failed to fetch spreadsheet data' }, { status: 500 });
}
};

View File

@@ -0,0 +1,20 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { googleSheetsServer } from '$lib/google/sheets/server.js';
export const GET: RequestHandler = async ({ request }) => {
try {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return json({ error: 'Missing or invalid authorization header' }, { status: 401 });
}
const refreshToken = authHeader.slice(7);
const spreadsheets = await googleSheetsServer.getRecentSpreadsheets(refreshToken, 20);
return json(spreadsheets);
} catch (error) {
console.error('Error fetching recent spreadsheets:', error);
return json({ error: 'Failed to fetch spreadsheets' }, { status: 500 });
}
};

View File

@@ -0,0 +1,30 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { googleSheetsServer } from '$lib/google/sheets/server.js';
export const GET: RequestHandler = async ({ url, request }) => {
try {
// Get search query from URL
const query = url.searchParams.get('query');
if (!query) {
throw error(400, 'Search query is required');
}
// Get authorization token from request headers
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw error(401, 'Missing or invalid Authorization header');
}
const refreshToken = authHeader.substring(7); // Remove "Bearer " prefix
// Search for sheets using the query
const sheets = await googleSheetsServer.searchSheets(refreshToken, query);
// Return the search results
return json(sheets);
} catch (err) {
console.error('Error searching Google Sheets:', err);
throw error(500, 'Failed to search Google Sheets');
}
};

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import { onMount } from 'svelte';
import SingleEvent from './SingleEvent.svelte';
let { data } = $props();
// Types
interface Event {
id: string;
name: string;
date: string;
archived: boolean; // Whether the event is from events_archived table
}
// State
let allEvents = $state<Event[]>([]); // All events from both tables
let displayEvents = $state<Event[]>([]); // Events to display (filtered by search)
let loading = $state(true);
let error = $state('');
let searchTerm = $state('');
let isSearching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
onMount(async () => {
await loadEvents();
});
async function loadEvents() {
loading = true;
error = '';
try {
// Fetch regular events
const { data: eventsData, error: eventsError } = await data.supabase
.from('events')
.select('id, name, date')
.order('date', { ascending: false });
if (eventsError) throw eventsError;
// Fetch archived events (limited to 20)
const { data: archivedEventsData, error: archivedError } = await data.supabase
.from('events_archived')
.select('id, name, date')
.order('date', { ascending: false })
.limit(20);
if (archivedError) throw archivedError;
// Merge both arrays, marking archived events
const regularEvents = (eventsData || []).map(event => ({ ...event, archived: false }));
const archivedEvents = (archivedEventsData || []).map(event => ({ ...event, archived: true }));
// Sort all events by date (newest first)
const combined = [...regularEvents, ...archivedEvents];
allEvents = combined;
displayEvents = allEvents;
} catch (err) {
console.error('Error loading events:', err);
error = 'Failed to load events';
} finally {
loading = false;
}
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
async function searchEvents(term: string) {
if (!term.trim()) {
displayEvents = allEvents;
return;
}
isSearching = true;
error = '';
try {
// Search regular events
const { data: regularResults, error: regularError } = await data.supabase
.from('events')
.select('id, name, date')
.ilike('name', `%${term}%`)
.order('date', { ascending: false });
if (regularError) {
console.error('Regular events search error:', regularError);
throw regularError;
}
// Search archived events
const { data: archivedResults, error: archivedError } = await data.supabase
.from('events_archived')
.select('id, name, date')
.ilike('name', `%${term}%`)
.order('date', { ascending: false })
.limit(50);
if (archivedError) {
console.error('Archived events search error:', archivedError);
throw archivedError;
}
// Merge search results
const regularEvents = (regularResults || []).map(event => ({ ...event, archived: false }));
const archivedEvents = (archivedResults || []).map(event => ({ ...event, archived: true }));
// Sort merged results by date (newest first)
const combined = [...regularEvents, ...archivedEvents];
combined.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
displayEvents = combined;
} catch (err) {
console.error('Error searching events:', err);
} finally {
isSearching = false;
}
}
// Handle search term changes
function handleSearchInput() {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
searchEvents(searchTerm);
}, 300);
}
function clearSearch() {
searchTerm = '';
displayEvents = allEvents;
}
</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">
{#if loading}
<!-- Loading placeholders -->
{#each Array(4) as _}
<div class="block border border-gray-300 rounded bg-white p-4 min-h-[72px]">
<div class="flex flex-col gap-1">
<div class="h-6 w-3/4 bg-gray-200 rounded animate-pulse"></div>
<div class="h-4 w-1/2 bg-gray-100 rounded animate-pulse"></div>
</div>
</div>
{/each}
{:else if error}
<div class="col-span-full text-center py-8">
<p class="text-red-600">{error}</p>
<button
onclick={loadEvents}
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Try Again
</button>
</div>
{:else if displayEvents.length === 0}
<div class="col-span-full text-center py-8">
<p class="text-gray-500">No events found. Create your first event!</p>
</div>
{:else}
{#each displayEvents as event}
<SingleEvent
id={event.id}
name={event.name}
date={formatDate(event.date)}
archived={event.archived}
/>
{/each}
{/if}
</div>
<!-- Bottom actions - Mobile optimized -->
<div class="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-300 shadow-lg pb-safe">
<!-- Search bar and New Event button layout -->
<div class="max-w-2xl mx-auto px-4 py-3 flex flex-col sm:flex-row gap-3 sm:items-center">
<!-- Search bar - Full width on mobile, adaptive on desktop -->
<div class="relative flex-grow">
<input
type="text"
bind:value={searchTerm}
oninput={handleSearchInput}
placeholder="Search events..."
class="w-full pl-10 pr-10 py-2.5 rounded-lg border border-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<div class="absolute left-3 top-1/2 -translate-y-1/2">
{#if isSearching}
<svg class="animate-spin h-4 w-4 text-gray-400" 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-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{:else}
<svg class="h-4 w-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{/if}
</div>
{#if searchTerm}
<button
onclick={clearSearch}
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search"
>
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- New Event button - Adaptive width -->
<a
href="/private/events/event/new"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2.5 px-6 rounded-lg transition text-center whitespace-nowrap sm:flex-shrink-0"
>
<span class="flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New Event
</span>
</a>
</div>
</div>
<!-- Add padding to bottom of content to prevent overlap with fixed bottom bar -->
<div class="h-24"></div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
const { id, name, date, archived = false } = $props();
</script>
<a
href={archived ? `/private/events/event/archived?id=${id}` : `/private/events/event/view?id=${id}`}
class="block border border-gray-300 rounded bg-white p-4 shadow-none transition cursor-pointer hover:border-blue-500 group min-h-[72px] h-full w-full"
aria-label={archived ? `View archived event ${name}` : `View event ${name}`}
>
<div class="flex flex-col gap-1">
<span class="font-semibold text-lg text-black-700 group-hover:underline flex items-center gap-2">
{#if archived}
<svg class="inline w-5 h-5 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="8" width="16" height="10" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><path d="M8 8V6a4 4 0 1 1 8 0v2" stroke="currentColor" stroke-width="2" fill="none"/></svg>
{/if}
{name}
</span>
<span class="text-gray-500 text-sm">{date}</span>
</div>
</a>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import EventInformation from './components/EventInformation.svelte';
import Statistics from './components/Statistics.svelte';
let { data } = $props();
// Types
interface ArchivedEvent {
id: string;
name: string;
date: string;
total_participants: number;
scanned_participants: number;
}
let event_data = $state<ArchivedEvent | null>(null);
let loading = $state(true);
onMount(async () => {
const event_id = page.url.searchParams.get('id');
if (!event_id) {
loading = false;
return;
}
const { data: event } = await data.supabase
.from('events_archived')
.select('*')
.eq('id', event_id)
.single();
event_data = event;
loading = false;
});
</script>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">Archived Event Overview</h1>
<EventInformation
event={event_data}
loading={loading}
/>
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Event Statistics</h2>
<Statistics
loading={loading}
totalParticipants={event_data?.total_participants ?? 0}
scannedParticipants={event_data?.scanned_participants ?? 0}
/>
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
interface ArchivedEvent {
name: string;
date: string;
}
let { event, loading } = $props<{
event: ArchivedEvent | null;
loading: boolean;
}>();
</script>
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">
<div class="flex flex-col gap-1">
{#if loading}
<div class="h-6 w-40 bg-gray-200 rounded animate-pulse mb-2"></div>
<div class="h-4 w-24 bg-gray-100 rounded animate-pulse"></div>
{:else}
<h2 class="mb-2 text-2xl font-semibold text-gray-900">{event?.name}</h2>
<div class="space-y-3">
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Date:</span>
<span class="text-sm text-gray-900">{event?.date}</span>
</div>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
let {
loading,
totalParticipants,
scannedParticipants
} = $props<{
loading: boolean;
totalParticipants: number;
scannedParticipants: number;
}>();
</script>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-sm font-medium text-gray-700">Category</th>
<th class="px-4 py-2 text-right text-sm font-medium text-gray-700">Count</th>
</tr>
</thead>
<tbody>
<!-- Total participants -->
<tr class="border-b border-gray-100">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<svg
class="inline h-4 w-4 text-blue-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" />
</svg>
<span class="text-sm font-medium text-gray-700">Total participants</span>
</div>
</td>
<td class="px-4 py-3 text-right">
{#if loading}
<div class="ml-auto h-4 w-10 bg-gray-200 rounded animate-pulse"></div>
{:else}
<span class="font-semibold text-gray-900">{totalParticipants}</span>
{/if}
</td>
</tr>
<!-- Scanned participants -->
<tr class="border-b border-gray-100">
<td class="px-4 py-3">
<div class="flex items-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 font-medium text-gray-700">Scanned participants</span>
</div>
</td>
<td class="px-4 py-3 text-right">
{#if loading}
<div class="ml-auto h-4 w-10 bg-gray-200 rounded animate-pulse"></div>
{:else}
<span class="font-semibold text-gray-900">{scannedParticipants}</span>
{/if}
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,458 @@
<script lang="ts">
import { onMount } from 'svelte';
import { isTokenValid, getUserInfo, revokeToken } from '$lib/google/auth/client.js';
import type { GoogleSheet } from '$lib/google/sheets/types.ts';
import { goto } from '$app/navigation';
// Import Components
import GoogleAuthStep from './components/GoogleAuthStep.svelte';
import EventDetailsStep from './components/EventDetailsStep.svelte';
import GoogleSheetsStep from './components/GoogleSheetsStep.svelte';
import EmailSettingsStep from './components/EmailSettingsStep.svelte';
import StepNavigator from './components/StepNavigator.svelte';
import StepNavigation from './components/StepNavigation.svelte';
let { data } = $props();
// Step management
let currentStep = $state(0); // Start at step 0 for Google auth check
const totalSteps = 4; // Increased to include auth step
// Step 0: Google Auth
let authData = $state({
isConnected: false,
checking: true,
connecting: false,
showCancelOption: false,
token: null as string | null,
error: null as string | null,
userEmail: null as string | null
});
// Step 1: Event Details
let eventData = $state({
name: '',
date: ''
});
// Step 2: Google Sheets
let sheetsData = $state({
availableSheets: [] as GoogleSheet[],
selectedSheet: null as GoogleSheet | null,
sheetData: [] as string[][],
columnMapping: {
name: 0, // Initialize to 0 (no column selected)
surname: 0,
email: 0,
confirmation: 0
},
loading: false,
expandedSheetList: true // Add this flag to control sheet list expansion
});
// Step 3: Email
let emailData = $state({
subject: '',
body: ''
});
// General state
let loading = $state(false);
let errors = $state<Record<string, string>>({});
onMount(async () => {
// Check Google auth status on mount
await checkGoogleAuth();
if (currentStep === 2) {
await loadRecentSheets();
}
});
// Google Auth functions
async function checkGoogleAuth() {
authData.checking = true;
try {
const accessToken = localStorage.getItem('google_access_token');
const refreshToken = localStorage.getItem('google_refresh_token');
if (accessToken && refreshToken) {
// Check if token is still valid
const isValid = await isTokenValid(accessToken);
authData.isConnected = isValid;
authData.token = accessToken;
if (isValid) {
// Fetch user info
await fetchUserInfo(accessToken);
}
} else {
authData.isConnected = false;
authData.userEmail = null;
}
} catch (error) {
console.error('Error checking Google auth:', error);
authData.isConnected = false;
authData.error = 'Error checking Google connection';
authData.userEmail = null;
} finally {
authData.checking = false;
}
}
async function connectToGoogle() {
authData.error = '';
authData.connecting = true;
try {
// Open popup window for OAuth
const popup = window.open(
'/auth/google',
'google-auth',
'width=500,height=600,scrollbars=yes,resizable=yes,left=' +
Math.round(window.screen.width / 2 - 250) + ',top=' +
Math.round(window.screen.height / 2 - 300)
);
if (!popup) {
authData.error = 'Failed to open popup window. Please allow popups for this site.';
authData.connecting = false;
return;
}
let authCompleted = false;
let popupTimer: number | null = null;
let cancelTimeout: number | null = null;
// Store current timestamp to detect changes in localStorage
const startTimestamp = localStorage.getItem('google_auth_timestamp') || '0';
// Poll localStorage for auth completion
const pollInterval = setInterval(() => {
try {
const currentTimestamp = localStorage.getItem('google_auth_timestamp');
// If timestamp has changed, auth is complete
if (currentTimestamp && currentTimestamp !== startTimestamp) {
handleAuthSuccess();
}
} catch (e) {
console.error('Error checking auth timestamp:', e);
}
}, 500); // Poll every 500ms
// Common handler for authentication success
function handleAuthSuccess() {
if (authCompleted) return; // Prevent duplicate handling
authCompleted = true;
authData.connecting = false;
authData.showCancelOption = false;
// Clean up timers
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
// Update auth state
setTimeout(checkGoogleAuth, 100);
}
// Clean up function to handle all cleanup in one place
const cleanUp = () => {
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
authData.connecting = false;
};
// Set a timeout for initial auth check
popupTimer = setTimeout(() => {
// Only check if auth isn't already completed
if (!authCompleted) {
cleanUp();
// Check if tokens were stored by the popup before it was closed
setTimeout(checkGoogleAuth, 100);
}
}, 30 * 1000) as unknown as number; // Reduced from 60s to 30s
// Show cancel option sooner
cancelTimeout = setTimeout(() => {
if (!authCompleted) {
authData.showCancelOption = true;
}
}, 10 * 1000) as unknown as number; // Reduced from 20s to 10s
// Final cleanup timeout
setTimeout(() => {
if (!authCompleted) {
cleanUp();
}
}, 60 * 1000); // Reduced from 3min to 1min
} catch (error) {
console.error('Error connecting to Google:', error);
authData.error = 'Failed to connect to Google';
authData.connecting = false;
}
}
function cancelGoogleAuth() {
authData.connecting = false;
authData.showCancelOption = false;
}
async function fetchUserInfo(accessToken: string) {
try {
// Use the new getUserInfo function from our lib
const userData = await getUserInfo(accessToken);
if (userData) {
authData.userEmail = userData.email;
} else {
authData.userEmail = null;
}
} catch (error) {
console.error('Error fetching user info:', error);
authData.userEmail = null;
}
}
async function disconnectGoogle() {
try {
// First revoke the token at Google using our API
const accessToken = localStorage.getItem('google_access_token');
if (accessToken) {
await revokeToken(accessToken);
}
// Remove tokens from local storage
localStorage.removeItem('google_access_token');
localStorage.removeItem('google_refresh_token');
// Update auth state
authData.isConnected = false;
authData.token = null;
authData.userEmail = null;
// Clear any selected sheets data
sheetsData.availableSheets = [];
sheetsData.selectedSheet = null;
sheetsData.sheetData = [];
} catch (error) {
console.error('Error disconnecting from Google:', error);
authData.error = 'Failed to disconnect from Google';
}
}
// Step navigation
function nextStep() {
if (validateCurrentStep()) {
currentStep = Math.min(currentStep + 1, totalSteps - 1);
if (currentStep === 2) {
loadRecentSheets();
}
}
}
function prevStep() {
currentStep = Math.max(currentStep - 1, 0);
}
function validateCurrentStep(): boolean {
errors = {};
if (currentStep === 0) {
if (!authData.isConnected) {
errors.auth = 'Please connect your Google account to continue';
return false;
}
} else if (currentStep === 1) {
if (!eventData.name.trim()) {
errors.name = 'Event name is required';
}
if (!eventData.date) {
errors.date = 'Event date is required';
}
} else if (currentStep === 2) {
if (!sheetsData.selectedSheet) {
errors.sheet = 'Please select a Google Sheet';
}
if (sheetsData.selectedSheet) {
// Validate column mappings
const { name, surname, email, confirmation } = sheetsData.columnMapping;
const missingColumns = [];
if (!name) missingColumns.push('Name');
if (!surname) missingColumns.push('Surname');
if (!email) missingColumns.push('Email');
if (!confirmation) missingColumns.push('Confirmation');
if (missingColumns.length > 0) {
errors.sheetData = `Please map the following columns: ${missingColumns.join(', ')}`;
}
}
} else if (currentStep === 3) {
if (!emailData.subject.trim()) {
errors.subject = 'Email subject is required';
}
if (!emailData.body.trim()) {
errors.body = 'Email body is required';
}
}
return Object.keys(errors).length === 0;
}
// Google Sheets functions
async function loadRecentSheets() {
sheetsData.loading = true;
// Always expand the sheet list when loading new sheets
sheetsData.expandedSheetList = true;
try {
// Use the new unified API endpoint
const response = await fetch('/private/api/google/sheets/recent', {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}`
}
});
if (response.ok) {
sheetsData.availableSheets = await response.json();
}
} catch (error) {
console.error('Error loading sheets:', error);
errors.sheets = 'Failed to load Google Sheets';
} finally {
sheetsData.loading = false;
}
}
async function selectSheet(sheet: GoogleSheet) {
const sameSheet = sheetsData.selectedSheet?.id === sheet.id;
sheetsData.selectedSheet = sheet;
sheetsData.loading = true;
// Collapse sheet list when selecting a new sheet
if (!sameSheet) {
sheetsData.expandedSheetList = false;
}
try {
// Use the new unified API endpoint
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}`
}
});
if (response.ok) {
const data = await response.json();
sheetsData.sheetData = data.values || [];
}
} catch (error) {
console.error('Error loading sheet data:', error);
errors.sheetData = 'Failed to load sheet data';
} finally {
sheetsData.loading = false;
}
}
// Toggle the sheet list expansion
function toggleSheetList() {
sheetsData.expandedSheetList = !sheetsData.expandedSheetList;
}
// Final submission
async function createEvent() {
if (!validateCurrentStep()) return;
loading = true;
try {
const { error } = await data.supabase.rpc('create_event', {
p_name: eventData.name,
p_date: eventData.date,
p_email_subject: emailData.subject,
p_email_body: emailData.body,
p_sheet_id: sheetsData.selectedSheet?.id,
p_name_column: sheetsData.columnMapping.name,
p_surname_column: sheetsData.columnMapping.surname,
p_email_column: sheetsData.columnMapping.email,
p_confirmation_column: sheetsData.columnMapping.confirmation
});
if (error) throw error;
// Redirect to events list or show success message
goto('/private/events');
} catch (error) {
console.error('Error creating event:', error);
errors.submit = 'Failed to create event. Please try again.';
} finally {
loading = false;
}
}
// Computed values
let canProceed = $derived(() => {
if (currentStep === 0) return authData.isConnected;
if (currentStep === 1) return eventData.name && eventData.date;
if (currentStep === 2) {
const { name, surname, email, confirmation } = sheetsData.columnMapping;
return sheetsData.selectedSheet && name && surname && email && confirmation;
}
if (currentStep === 3) return emailData.subject && emailData.body;
return false;
});
</script>
<div class="max-w-4xl mx-auto p-6">
<!-- Header -->
<StepNavigator {currentStep} {totalSteps} />
<!-- Step Content -->
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
{#if currentStep === 0}
<GoogleAuthStep
bind:errors
onSuccess={(token) => {
authData.error = null;
authData.token = token;
authData.isConnected = true;
setTimeout(checkGoogleAuth, 100);
}}
onError={(error) => {
authData.error = error;
authData.isConnected = false;
}}
/>
{:else if currentStep === 1}
<EventDetailsStep bind:eventData bind:errors />
{:else if currentStep === 2}
<GoogleSheetsStep bind:sheetsData bind:errors {loadRecentSheets} {selectSheet} {toggleSheetList} />
{:else if currentStep === 3}
<EmailSettingsStep bind:emailData bind:errors />
{/if}
{#if errors.submit}
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded">
<p class="text-sm text-red-600">{errors.submit}</p>
</div>
{/if}
</div>
<!-- Navigation -->
<StepNavigation
{currentStep}
{totalSteps}
{canProceed}
{loading}
{prevStep}
{nextStep}
{createEvent}
/>
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
let { emailData = $bindable(), errors = $bindable() } = $props<{
emailData: {
subject: string;
body: string;
};
errors: Record<string, string>;
}>();
</script>
<div class="space-y-6">
<div>
<label for="emailSubject" class="block text-sm font-medium text-gray-700 mb-2">
Email Subject *
</label>
<input
id="emailSubject"
type="text"
bind:value={emailData.subject}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Event invitation subject"
/>
{#if errors.subject}
<p class="mt-1 text-sm text-red-600">{errors.subject}</p>
{/if}
</div>
<div>
<label for="emailBody" class="block text-sm font-medium text-gray-700 mb-2">
Email Body *
</label>
<textarea
id="emailBody"
bind:value={emailData.body}
rows="8"
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Email message content..."
></textarea>
{#if errors.body}
<p class="mt-1 text-sm text-red-600">{errors.body}</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
let { eventData = $bindable(), errors = $bindable() } = $props<{
eventData: {
name: string;
date: string;
};
errors: Record<string, string>;
}>();
</script>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Event details</h3>
<label for="eventName" class="block text-sm font-medium text-gray-700 mb-2">
Event Name *
</label>
<input
id="eventName"
type="text"
bind:value={eventData.name}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter event name"
/>
{#if errors.name}
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
{/if}
</div>
<div>
<label for="eventDate" class="block text-sm font-medium text-gray-700 mb-2">
Event Date *
</label>
<input
id="eventDate"
type="date"
bind:value={eventData.date}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{#if errors.date}
<p class="mt-1 text-sm text-red-600">{errors.date}</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props
let { errors, onSuccess, onError } = $props<{
errors: Record<string, string>;
onSuccess?: (token: string) => void;
onError?: (error: string) => void;
}>();
</script>
<div class="space-y-6">
<div class="text-center">
<h3 class="text-lg font-medium text-gray-900 mb-4">Connect Your Google Account</h3>
<p class="text-gray-600 mb-4">
To create events and import participants from Google Sheets, you need to connect your Google account.
</p>
<GoogleAuthButton
size="large"
variant="primary"
onSuccess={onSuccess}
onError={onError}
/>
{#if errors.google}
<div class="mt-4 text-sm text-red-600">
{errors.google}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,382 @@
<script lang="ts">
import type { GoogleSheet } from '$lib/google/sheets/types.ts';
// Props
let { sheetsData = $bindable(), errors = $bindable(), loadRecentSheets, selectSheet, toggleSheetList } = $props<{
sheetsData: {
availableSheets: GoogleSheet[];
selectedSheet: GoogleSheet | null;
sheetData: string[][];
columnMapping: {
name: number;
surname: number;
email: number;
confirmation: number;
};
loading: boolean;
expandedSheetList: boolean;
};
errors: Record<string, string>;
loadRecentSheets: () => Promise<void>;
selectSheet: (sheet: GoogleSheet) => Promise<void>;
toggleSheetList: () => void;
}>();
// Search functionality
let searchQuery = $state('');
let isSearching = $state(false);
let searchResults = $state<GoogleSheet[]>([]);
let searchError = $state('');
// Debounce function for search
function debounce(func: (...args: any[]) => void, wait: number) {
let timeout: ReturnType<typeof setTimeout> | null = null;
return function(...args: any[]) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Search for sheets
const searchSheets = debounce(async () => {
if (!searchQuery.trim()) {
searchResults = [];
return;
}
isSearching = true;
searchError = '';
try {
const response = await fetch(`/private/api/google/sheets/search?query=${encodeURIComponent(searchQuery)}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
}
});
if (response.ok) {
searchResults = await response.json();
} else {
searchError = 'Failed to search for sheets';
console.error('Search error:', await response.text());
}
} catch (error) {
searchError = 'Error searching for sheets';
console.error('Search error:', error);
} finally {
isSearching = false;
}
}, 500);
// Clear search
function clearSearch() {
searchQuery = '';
searchResults = [];
searchError = '';
}
$effect(() => {
if (searchQuery) {
searchSheets();
} else {
searchResults = [];
}
});
</script>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Select Google Sheet</h3>
{#if sheetsData.loading && sheetsData.availableSheets.length === 0}
<div class="space-y-3">
{#each Array(5) as _}
<div class="p-4 border border-gray-200 rounded animate-pulse">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-3 bg-gray-100 rounded w-1/2"></div>
</div>
{/each}
</div>
{:else if sheetsData.availableSheets.length === 0}
<div class="text-center py-8">
<p class="text-gray-500">No Google Sheets found.</p>
<button
onclick={loadRecentSheets}
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
>
Refresh
</button>
</div>
{:else}
<div class="space-y-3">
{#if !sheetsData.expandedSheetList && sheetsData.selectedSheet}
<!-- Selected sheet only (collapsed view) -->
<div class="flex items-center justify-between p-4 border border-blue-500 bg-blue-50 rounded">
<div>
<div class="font-medium text-gray-900">{sheetsData.selectedSheet.name}</div>
<div class="text-sm text-gray-500">
Modified: {new Date(sheetsData.selectedSheet.modifiedTime).toLocaleDateString()}
</div>
</div>
<button
onclick={toggleSheetList}
class="text-blue-600 hover:text-blue-800 flex items-center"
aria-label="Show all sheets"
>
<span class="text-sm mr-1">Change</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
{:else}
<!-- All sheets and search (expanded view) -->
<div class="flex justify-between items-center mb-2">
<h4 class="text-sm font-medium text-gray-700">Google Sheets</h4>
{#if sheetsData.selectedSheet}
<button
onclick={toggleSheetList}
class="text-sm text-blue-600 hover:text-blue-800"
aria-label="Hide sheet list"
>
Collapse list
</button>
{/if}
</div>
<!-- Search bar -->
<div class="relative mb-4">
<input
type="text"
bind:value={searchQuery}
placeholder="Search all your Google Sheets..."
class="w-full px-4 py-2 pl-10 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{#if searchQuery}
<button
onclick={clearSearch}
class="absolute inset-y-0 right-0 pr-3 flex items-center"
aria-label="Clear search"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{#if isSearching}
<!-- Loading state -->
<div class="space-y-3">
{#each Array(3) as _}
<div class="p-4 border border-gray-200 rounded animate-pulse">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-3 bg-gray-100 rounded w-1/2"></div>
</div>
{/each}
</div>
{:else if searchQuery && searchResults.length === 0 && !searchError}
<!-- No search results -->
<div class="text-center py-6 border border-gray-200 rounded">
<p class="text-gray-500">No sheets found matching "{searchQuery}"</p>
</div>
{:else if searchError}
<!-- Search error -->
<div class="text-center py-6 border border-red-200 bg-red-50 rounded">
<p class="text-red-600">{searchError}</p>
<button
onclick={searchSheets}
class="mt-2 px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition"
>
Retry
</button>
</div>
{:else if searchQuery && searchResults.length > 0}
<!-- Search results -->
<div class="grid gap-3">
{#each searchResults as sheet}
<button
onclick={() => selectSheet(sheet)}
class="p-4 text-left border border-gray-200 rounded hover:border-blue-500 transition {
sheetsData.selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : ''
}"
>
<div class="font-medium text-gray-900">
{#if searchQuery}
{#each sheet.name.split(new RegExp(`(${searchQuery})`, 'i')) as part}
{#if part.toLowerCase() === searchQuery.toLowerCase()}
<span class="bg-yellow-200">{part}</span>
{:else}
{part}
{/if}
{/each}
{:else}
{sheet.name}
{/if}
</div>
<div class="text-sm text-gray-500">
Modified: {new Date(sheet.modifiedTime).toLocaleDateString('en-GB', {day: '2-digit', month: '2-digit', year: 'numeric'})}
</div>
</button>
{/each}
</div>
{:else}
<!-- Recent sheets (when not searching) -->
<div class="grid gap-3">
{#each sheetsData.availableSheets as sheet}
<button
onclick={() => selectSheet(sheet)}
class="p-4 text-left border border-gray-200 rounded hover:border-blue-500 transition {
sheetsData.selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : ''
}"
>
<div class="font-medium text-gray-900">{sheet.name}</div>
<div class="text-sm text-gray-500">
Modified: {new Date(sheet.modifiedTime).toLocaleDateString('en-GB', {day: '2-digit', month: '2-digit', year: 'numeric'})}
</div>
</button>
{/each}
</div>
{#if sheetsData.availableSheets.length === 0 && !sheetsData.loading}
<div class="text-center py-6 border border-gray-200 rounded">
<p class="text-gray-500">No recent sheets found. Try searching above.</p>
</div>
{/if}
{/if}
{/if}
</div>
{/if}
{#if errors.sheet}
<p class="mt-2 text-sm text-red-600">{errors.sheet}</p>
{/if}
</div>
{#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0}
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Column Mapping</h3>
<!-- Instructions for column mapping -->
<div class="bg-white p-4 rounded-md border border-gray-300 mb-4">
<p class="text-sm text-black-800 mb-2 font-medium">Column Mapping Instructions:</p>
<p class="text-sm text-black-700">
Select what each column represents by using the dropdown in each column header.
Make sure to assign Name, Surname, Email, and Confirmation columns.
</p>
</div>
<div class="overflow-x-auto">
<table class="w-full border border-gray-200 rounded">
<thead>
<tr class="bg-gray-50">
{#each sheetsData.sheetData[0] || [] as header, index}
<th class="px-3 py-2 border-b border-gray-200 text-left">
<div class="flex flex-col gap-2">
<div class="font-medium text-gray-900">
{header || `Empty Column ${index + 1}`}
</div>
<select
class="text-sm normal-case font-normal px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
aria-label={`Select data type for column ${index + 1}`}
onclick={(e) => e.stopPropagation()}
onchange={(e) => {
const value = (e.target as HTMLSelectElement).value;
if (value === "none") return;
// Reset previous selection if this column was already mapped
if (sheetsData.columnMapping.name === index + 1) sheetsData.columnMapping.name = 0;
if (sheetsData.columnMapping.surname === index + 1) sheetsData.columnMapping.surname = 0;
if (sheetsData.columnMapping.email === index + 1) sheetsData.columnMapping.email = 0;
if (sheetsData.columnMapping.confirmation === index + 1) sheetsData.columnMapping.confirmation = 0;
// Set new mapping
if (value === "name") sheetsData.columnMapping.name = index + 1;
else if (value === "surname") sheetsData.columnMapping.surname = index + 1;
else if (value === "email") sheetsData.columnMapping.email = index + 1;
else if (value === "confirmation") sheetsData.columnMapping.confirmation = index + 1;
}}
>
<option value="none">Select data type</option>
<option value="name" selected={sheetsData.columnMapping.name === index + 1}>Name</option>
<option value="surname" selected={sheetsData.columnMapping.surname === index + 1}>Surname</option>
<option value="email" selected={sheetsData.columnMapping.email === index + 1}>Email</option>
<option value="confirmation" selected={sheetsData.columnMapping.confirmation === index + 1}>Confirmation</option>
</select>
<div class="h-7 mt-1"> <!-- Fixed height container to prevent layout shift -->
{#if sheetsData.columnMapping.name === index + 1}
<span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">Name Column</span>
{:else if sheetsData.columnMapping.surname === index + 1}
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">Surname Column</span>
{:else if sheetsData.columnMapping.email === index + 1}
<span class="bg-purple-100 text-purple-800 text-xs px-2 py-1 rounded">Email Column</span>
{:else if sheetsData.columnMapping.confirmation === index + 1}
<span class="bg-amber-100 text-amber-800 text-xs px-2 py-1 rounded">Confirmation Column</span>
{:else}
<span class="bg-gray-100 text-gray-400 text-xs px-2 py-1 rounded">Not Mapped</span>
{/if}
</div>
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sheetsData.sheetData.slice(0, 10) as row, rowIndex}
<tr class="hover:bg-gray-50">
{#each row as cell, cellIndex}
<td class="px-3 py-2 border-b border-gray-100 text-sm">
<span
class={
sheetsData.columnMapping.name === cellIndex + 1 ? 'font-medium text-green-700' :
sheetsData.columnMapping.surname === cellIndex + 1 ? 'font-medium text-blue-700' :
sheetsData.columnMapping.email === cellIndex + 1 ? 'font-medium text-purple-700' :
sheetsData.columnMapping.confirmation === cellIndex + 1 ? 'font-medium text-amber-700' :
'text-gray-700'
}
title={cell || ''}
>
{cell || ''}
</span>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<div class="flex justify-between mt-2">
<p class="text-sm text-gray-500">Showing first 10 rows</p>
{#if sheetsData.sheetData[0] && sheetsData.sheetData[0].length > 3}
<p class="text-sm text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline mr-1" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
Scroll horizontally to see all {sheetsData.sheetData[0].length} columns
</p>
{/if}
</div>
</div>
{/if}
{#if sheetsData.loading && sheetsData.selectedSheet}
<div class="text-center py-4">
<div class="text-gray-600">Loading sheet data...</div>
</div>
{/if}
{#if errors.sheetData}
<p class="text-sm text-red-600">{errors.sheetData}</p>
{/if}
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
// Props
let { currentStep, totalSteps, canProceed, loading, prevStep, nextStep, createEvent } = $props<{
currentStep: number;
totalSteps: number;
canProceed: boolean;
loading: boolean;
prevStep: () => void;
nextStep: () => void;
createEvent: () => Promise<void>;
}>();
</script>
<div class="flex items-center justify-between">
<button
onclick={prevStep}
disabled={currentStep === 0}
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<div class="flex gap-2">
{#if currentStep < totalSteps - 1}
<button
onclick={nextStep}
disabled={!canProceed}
class="px-6 py-2 bg-blue-600 text-white font-semibold rounded hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
{:else}
<button
onclick={createEvent}
disabled={!canProceed || loading}
class="px-6 py-2 bg-green-600 text-white font-semibold rounded hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Creating...' : 'Create Event'}
</button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
// Props
let { currentStep, totalSteps, stepTitle } = $props<{
currentStep: number;
totalSteps: number;
}>();
</script>
<div class="mb-8">
<div class="flex items-center justify-center gap-4 w-full">
{#each Array(totalSteps) as _, index}
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium {
index === currentStep ? 'bg-blue-600 text-white' :
index < currentStep ? 'bg-green-600 text-white' :
'bg-gray-200 text-gray-600'
}">
{index + 1}
</div>
{#if index < totalSteps - 1}
<div class="w-10 h-1 rounded {
index < currentStep ? 'bg-green-600' : 'bg-gray-200'
}"></div>
{/if}
</div>
{/each}
</div>
</div>

View File

@@ -0,0 +1,309 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
// Import components
import EventInformation from './components/EventInformation.svelte';
import EmailTemplate from './components/EmailTemplate.svelte';
import GoogleAuthentication from './components/GoogleAuthentication.svelte';
import ParticipantsTable from './components/ParticipantsTable.svelte';
import EmailSending from './components/EmailSending.svelte';
import EmailResults from './components/EmailResults.svelte';
import ErrorMessage from './components/ErrorMessage.svelte';
import Statistics from './components/Statistics.svelte';
let { data } = $props();
// Types
interface Event {
id: string;
created_at: string;
created_by: string;
name: string;
date: string;
section_id: string;
email_subject: string;
email_body: string;
sheet_id: string;
name_column: number;
surname_column: number;
email_column: number;
confirmation_column: number;
}
interface Participant {
id: string;
name: string;
surname: string;
email: string;
scanned: boolean;
email_sent: boolean;
}
// State management with Svelte 5 runes
let event = $state<Event | null>(null);
let participants = $state<Participant[]>([]);
let loading = $state(true);
let participantsLoading = $state(true);
let syncingParticipants = $state(false);
let sendingEmails = $state(false);
let emailProgress = $state({ sent: 0, total: 0 });
let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null);
let error = $state('');
// Get event ID from URL params
let eventId = $derived(page.url.searchParams.get('id'));
onMount(async () => {
if (eventId) {
await loadEvent();
await loadParticipants();
}
});
async function loadEvent() {
loading = true;
try {
const { data: eventData, error: eventError } = await data.supabase
.from('events')
.select('*')
.eq('id', eventId)
.single();
if (eventError) throw eventError;
event = eventData;
} catch (err) {
console.error('Error loading event:', err);
error = 'Failed to load event';
} finally {
loading = false;
}
}
async function loadParticipants() {
participantsLoading = true;
try {
const { data: participantsData, error: participantsError } = await data.supabase
.from('participants')
.select('id, name, surname, email, scanned, email_sent')
.eq('event', eventId)
.order('scanned', { ascending: true })
.order('email_sent', { ascending: true })
.order('name', { ascending: true });
if (participantsError) throw participantsError;
participants = participantsData || [];
} catch (err) {
console.error('Error loading participants:', err);
error = 'Failed to load participants';
} finally {
participantsLoading = false;
}
}
async function syncParticipants() {
if (!event || !event.sheet_id) return;
// Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
error = 'Please connect your Google account first to sync participants';
return;
}
syncingParticipants = true;
error = '';
try {
// Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
method: 'GET',
headers: {
Authorization: `Bearer ${refreshToken}`
}
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Google authentication expired. Please reconnect your Google account.');
}
throw new Error('Failed to fetch sheet data');
}
const sheetData = await response.json();
const rows = sheetData.values || [];
if (rows.length === 0) throw new Error('No data found in sheet');
// Extract participant data based on column mapping
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
// Skip header row (start from index 1)
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
if (row.length > 0) {
const name = row[event.name_column - 1] || '';
const surname = row[event.surname_column - 1] || '';
const email = row[event.email_column - 1] || '';
const confirmation = row[event.confirmation_column - 1] || '';
// Only add if the row has meaningful data (not all empty) AND confirmation is TRUE
const isConfirmed =
confirmation.toString().toLowerCase() === 'true' ||
confirmation.toString().toLowerCase() === 'yes' ||
confirmation === '1' ||
confirmation === 'x';
if ((name.trim() || surname.trim() || email.trim()) && isConfirmed) {
names.push(name.trim());
surnames.push(surname.trim());
emails.push(email.trim());
}
}
}
// Call database function to add participants
const { error: syncError } = await data.supabase.rpc('participants_add_bulk', {
p_event: eventId,
p_names: names,
p_surnames: surnames,
p_emails: emails
});
if (syncError) throw syncError;
// Reload participants
await loadParticipants();
} catch (err) {
console.error('Error syncing participants:', err);
error = 'Failed to sync participants';
} finally {
syncingParticipants = false;
}
}
async function sendEmailsToUncontacted() {
if (!event) return;
// Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
error = 'Please connect your Google account first to send emails';
return;
}
const uncontactedParticipants = participants.filter(p => !p.email_sent);
if (uncontactedParticipants.length === 0) {
error = 'No uncontacted participants found';
return;
}
sendingEmails = true;
emailProgress = { sent: 0, total: uncontactedParticipants.length };
emailResults = null;
error = '';
try {
// Send all emails in batch
const response = await fetch('/private/api/google/gmail', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
participants: uncontactedParticipants,
subject: event.email_subject,
text: event.email_body,
eventId: event.id,
refreshToken: refreshToken
})
});
if (response.ok) {
const result = await response.json();
emailProgress.sent = result.summary.success;
emailResults = result;
// Update participants state to reflect email_sent status
participants = participants.map(p => {
const emailedParticipant = result.results.find((r: any) => r.participant.id === p.id);
if (emailedParticipant && emailedParticipant.success) {
return { ...p, email_sent: true };
}
return p;
});
} else {
const errorData = await response.json();
error = errorData.error || 'Failed to send emails';
console.error('Email sending failed:', errorData);
}
} catch (err) {
console.error('Error sending emails:', err);
error = 'Failed to send emails to participants';
} finally {
sendingEmails = false;
}
}
function handleGoogleAuthSuccess() {
error = '';
}
function handleGoogleAuthError(errorMsg: string) {
error = errorMsg;
}
</script>
<!-- Header -->
<div class="mt-2 mb-4">
<h1 class="text-center text-2xl font-bold">Event Overview</h1>
</div>
<!-- Composable components -->
<EventInformation {event} {loading} {error} />
<GoogleAuthentication
{loading}
onSuccess={handleGoogleAuthSuccess}
onError={handleGoogleAuthError}
/>
<ParticipantsTable
{event}
{participants}
{loading}
participantsLoading={participantsLoading}
syncingParticipants={syncingParticipants}
onSyncParticipants={syncParticipants}
/>
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-4 text-xl font-semibold text-gray-900">Statistics</h2>
<Statistics
loading={loading || participantsLoading}
totalCount={participants.length}
scannedCount={participants.filter(p => p.scanned).length}
emailSentCount={participants.filter(p => p.email_sent).length}
pendingCount={participants.filter(p => !p.email_sent).length}
/>
</div>
<EmailTemplate {event} {loading} />
<EmailSending
{loading}
{participants}
{sendingEmails}
{emailProgress}
{event}
onSendEmails={sendEmailsToUncontacted}
/>
{#if emailResults}
<EmailResults {emailResults} />
{/if}
{#if error}
<ErrorMessage {error} />
{/if}

View File

@@ -0,0 +1,100 @@
<script lang="ts">
interface EmailResultItem {
participant: {
id: string;
name: string;
surname: string;
email: string;
};
success: boolean;
error?: string;
}
interface EmailResultsSummary {
success: number;
errors: number;
total: number;
}
interface EmailResultsData {
success: boolean;
results: EmailResultItem[];
summary: EmailResultsSummary;
}
let { emailResults } = $props<{
emailResults: EmailResultsData | null;
}>();
</script>
{#if emailResults}
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Email Results</h2>
<div class="text-sm text-gray-500">
{emailResults.summary.success} successful, {emailResults.summary.errors} failed
</div>
</div>
<div class="mb-4">
<div class="flex items-center gap-4 p-3 rounded-lg bg-gray-50">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="text-sm font-medium">Sent: {emailResults.summary.success}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<span class="text-sm font-medium">Failed: {emailResults.summary.errors}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
<span class="text-sm font-medium">Total: {emailResults.summary.total}</span>
</div>
</div>
</div>
{#if emailResults.results.length > 0}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Name</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Email</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Status</th>
</tr>
</thead>
<tbody>
{#each emailResults.results as result}
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-900">
{result.participant.name} {result.participant.surname}
</td>
<td class="px-4 py-3 text-sm text-gray-900">{result.participant.email}</td>
<td class="px-4 py-3 text-sm">
{#if result.success}
<div class="flex items-center text-green-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-sm font-medium">Sent</span>
</div>
{:else}
<div class="flex items-center text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="text-sm font-medium">Failed</span>
{#if result.error}
<span class="text-xs text-red-500 ml-2">({result.error})</span>
{/if}
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,94 @@
<script lang="ts">
interface Participant {
id: string;
email_sent: boolean;
}
interface EmailProgress {
sent: number;
total: number;
}
let {
loading,
participants,
sendingEmails,
emailProgress,
event,
onSendEmails
} = $props<{
loading: boolean;
participants: Participant[];
sendingEmails: boolean;
emailProgress: EmailProgress;
event: any | null;
onSendEmails: () => void;
}>();
// Using the $derived rune to calculate uncontacted participants
let uncontactedParticipantsCount = $derived(participants.filter(p => !p.email_sent).length);
</script>
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Send Emails</h2>
{#if !loading}
<div class="text-sm text-gray-500">
{uncontactedParticipantsCount} uncontacted participants
</div>
{:else}
<div class="text-sm text-gray-500">
Loading participants...
</div>
{/if}
</div>
{#if loading}
<div class="h-16 w-full animate-pulse rounded bg-gray-200"></div>
{:else if sendingEmails}
<div class="rounded-lg bg-blue-50 p-4 border border-blue-200">
<div class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" 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-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-blue-800 font-medium">Sending {emailProgress.total} emails... Please wait.</span>
</div>
</div>
{:else}
<div class="space-y-4">
{#if uncontactedParticipantsCount > 0}
<div class="rounded-lg bg-yellow-50 p-4 border border-yellow-200">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 sm:h-6 sm:w-6 text-yellow-600 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<span class="text-yellow-800 text-sm sm:text-base">
<strong>Warning:</strong> Do not close this window while emails are being sent. The process may take several minutes.
</span>
</div>
</div>
<div class="flex items-center justify-end">
<button
onclick={onSendEmails}
disabled={!event || uncontactedParticipantsCount === 0}
class="rounded bg-green-600 px-4 py-2 text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Send Emails
</button>
</div>
{:else}
<div class="text-center py-4">
<div class="flex items-center justify-center text-green-600 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
<p class="text-green-700 font-medium">All participants have been contacted!</p>
<p class="text-sm text-green-600">No pending emails to send.</p>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
interface Participant {
id: string;
email_sent: boolean;
}
interface EmailProgress {
sent: number;
total: number;
}
let {
loading,
participants,
sendingEmails,
emailProgress,
event,
onSendEmails
} = $props<{
loading: boolean;
participants: Participant[];
sendingEmails: boolean;
emailProgress: EmailProgress;
event: any | null;
onSendEmails: () => void;
}>();
// Using the $derived rune to calculate uncontacted participants
let uncontactedParticipantsCount = $derived(participants.filter(p => !p.email_sent).length);
</script>
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Send Emails</h2>
{#if !loading}
<div class="text-sm text-gray-500">
{uncontactedParticipantsCount} uncontacted participants
</div>
{:else}
<div class="text-sm text-gray-500">
Loading participants...
</div>
{/if}
</div>
{#if loading}
<div class="h-16 w-full animate-pulse rounded bg-gray-200"></div>
{:else if sendingEmails}
<div class="rounded-lg bg-blue-50 p-4 border border-blue-200">
<div class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" 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-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-blue-800 font-medium">Sending {emailProgress.total} emails... Please wait.</span>
</div>
</div>
{:else}
<div class="space-y-4">
{#if uncontactedParticipantsCount > 0}
<div class="rounded-lg bg-yellow-50 p-4 border border-yellow-200">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 sm:h-6 sm:w-6 text-yellow-600 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<span class="text-yellow-800 text-sm sm:text-base">
<strong>Warning:</strong> Do not close this window while emails are being sent. The process may take several minutes.
</span>
</div>
</div>
<div class="flex items-center justify-end">
<button
onclick={onSendEmails}
disabled={!event || uncontactedParticipantsCount === 0}
class="rounded bg-green-600 px-4 py-2 text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Send Emails
</button>
</div>
{:else}
<div class="text-center py-4">
<div class="flex items-center justify-center text-green-600 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
<p class="text-green-700 font-medium">All participants have been contacted!</p>
<p class="text-sm text-green-600">No pending emails to send.</p>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
interface Event {
email_subject: string;
email_body: string;
}
let { event, loading } = $props<{
event: Event | null;
loading: boolean;
}>();
</script>
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4">
<h2 class="text-xl font-semibold text-gray-900">Email Template</h2>
</div>
{#if loading}
<!-- Loading placeholder for email template content -->
<div class="space-y-4">
<div>
<span class="block mb-1 text-sm font-medium text-gray-700">Subject:</span>
<div class="h-10 w-full animate-pulse rounded bg-gray-200"></div>
</div>
<div>
<span class="block mb-1 text-sm font-medium text-gray-700">Body:</span>
<div class="h-28 w-full animate-pulse rounded bg-gray-200"></div>
</div>
</div>
{:else if event}
<div class="space-y-4">
<div>
<span class="block mb-1 text-sm font-medium text-gray-700">Subject:</span>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<p class="text-sm text-gray-900">{event.email_subject}</p>
</div>
</div>
<div>
<span class="block mb-1 text-sm font-medium text-gray-700">Body:</span>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200 max-h-48 overflow-y-auto">
<p class="text-sm whitespace-pre-wrap text-gray-900">{event.email_body}</p>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
let { error } = $props<{
error: string;
}>();
</script>
{#if error}
<div class="mt-4 rounded border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-600">{error}</p>
</div>
{/if}

View File

@@ -0,0 +1,88 @@
<script lang="ts">
interface Event {
name: string;
date: string;
created_at: string;
sheet_id: string;
}
let { event, loading, error } = $props<{
event: Event | null;
loading: boolean;
error: string;
}>();
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
</script>
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">
{#if loading}
<!-- Loading placeholder with header -->
<div>
<h2 class="mb-4 text-2xl font-semibold text-gray-900">Event Information</h2>
<div class="space-y-3">
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Date:</span>
<div class="h-4 w-1/4 animate-pulse rounded bg-gray-200"></div>
</div>
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Created:</span>
<div class="h-4 w-1/3 animate-pulse rounded bg-gray-200"></div>
</div>
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Sheet ID:</span>
<div class="h-4 w-1/2 animate-pulse rounded bg-gray-200"></div>
</div>
</div>
</div>
{:else if event}
<div>
<h2 class="mb-4 text-2xl font-semibold text-gray-900">{event.name}</h2>
<div class="space-y-3">
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Date:</span>
<span class="text-sm text-gray-900">{formatDate(event.date)}</span>
</div>
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Created:</span>
<span class="text-sm text-gray-900">{formatDate(event.created_at)}</span>
</div>
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Sheet ID:</span>
<a
href={`https://docs.google.com/spreadsheets/d/${event.sheet_id}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800 transition hover:bg-green-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
Open in Google Sheets
</a>
</div>
</div>
</div>
{:else if error}
<div class="py-8 text-center">
<p class="text-red-600">{error}</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
let { loading, onSuccess, onError } = $props<{
loading: boolean;
onSuccess: () => void;
onError: (message: string) => void;
}>();
</script>
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Google Account</h2>
<p class="text-sm text-gray-500">Required for syncing participants and sending emails</p>
</div>
{#if loading}
<div class="h-10 w-48 animate-pulse rounded bg-gray-200"></div>
{:else}
<GoogleAuthButton
size="small"
variant="secondary"
onSuccess={onSuccess}
onError={onError}
/>
{/if}
</div>

View File

@@ -0,0 +1,162 @@
<script lang="ts">
interface Participant {
id: string;
name: string;
surname: string;
email: string;
scanned: boolean;
email_sent: boolean;
}
interface Event {
id: string;
sheet_id: string;
}
let {
event,
participants,
loading,
participantsLoading,
syncingParticipants,
onSyncParticipants
} = $props<{
event: Event | null;
participants: Participant[];
loading: boolean;
participantsLoading: boolean;
syncingParticipants: boolean;
onSyncParticipants: () => void;
}>();
</script>
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Participants</h2>
<button
onclick={onSyncParticipants}
disabled={syncingParticipants || !event || loading}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if syncingParticipants}
Syncing...
{:else}
Sync Participants
{/if}
</button>
</div>
{#if participantsLoading || loading}
<!-- Loading placeholder for participants -->
<div class="space-y-3">
{#each Array(5) as _}
<div class="grid grid-cols-5 gap-4 border-b border-gray-200 py-3">
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
</div>
{/each}
</div>
{:else if participants.length > 0}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Name</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Surname</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Email</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Scanned</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Email Sent</th>
</tr>
</thead>
<tbody>
{#each participants as participant}
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-900">{participant.name}</td>
<td class="px-4 py-3 text-sm text-gray-900">{participant.surname}</td>
<td class="px-4 py-3 text-sm text-gray-900">{participant.email}</td>
<td class="px-4 py-3 text-sm">
{#if participant.scanned}
<div class="flex items-center text-green-600">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
{:else}
<div class="flex items-center text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
{/if}
</td>
<td class="px-4 py-3 text-sm">
{#if participant.email_sent}
<div class="flex items-center text-blue-600">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
{:else}
<div class="flex items-center text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="py-8 text-center">
<p class="text-gray-500">
No participants found. Click "Sync Participants" to load from Google Sheets.
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
let {
loading,
totalCount,
scannedCount,
emailSentCount,
pendingCount
} = $props<{
loading: boolean;
totalCount: number;
scannedCount: number;
emailSentCount: number;
pendingCount: number;
}>();
</script>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-sm font-medium text-gray-700">Category</th>
<th class="px-4 py-2 text-right text-sm font-medium text-gray-700">Count</th>
</tr>
</thead>
<tbody>
<!-- Total participants -->
<tr class="border-b border-gray-100">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<svg
class="inline h-4 w-4 text-blue-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" />
</svg>
<span class="text-sm font-medium text-gray-700">Total participants</span>
</div>
</td>
<td class="px-4 py-3 text-right">
{#if loading}
<div class="ml-auto h-4 w-10 bg-gray-200 rounded animate-pulse"></div>
{:else}
<span class="font-semibold text-gray-900">{totalCount}</span>
{/if}
</td>
</tr>
<!-- Scanned participants -->
<tr class="border-b border-gray-100">
<td class="px-4 py-3">
<div class="flex items-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 font-medium text-gray-700">Scanned participants</span>
</div>
</td>
<td class="px-4 py-3 text-right">
{#if loading}
<div class="ml-auto h-4 w-10 bg-gray-200 rounded animate-pulse"></div>
{:else}
<span class="font-semibold text-gray-900">{scannedCount}</span>
{/if}
</td>
</tr>
<!-- Email sent participants -->
<tr class="border-b border-gray-100">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<svg
class="inline h-4 w-4 text-blue-600"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="text-sm font-medium text-gray-700">Email sent</span>
</div>
</td>
<td class="px-4 py-3 text-right">
{#if loading}
<div class="ml-auto h-4 w-10 bg-gray-200 rounded animate-pulse"></div>
{:else}
<span class="font-semibold text-gray-900">{emailSentCount}</span>
{/if}
</td>
</tr>
<!-- Pending participants -->
<tr>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<svg
class="inline h-4 w-4 text-amber-500"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium text-gray-700">Pending emails</span>
</div>
</td>
<td class="px-4 py-3 text-right">
{#if loading}
<div class="ml-auto h-4 w-10 bg-gray-200 rounded animate-pulse"></div>
{:else}
<span class="font-semibold text-gray-900">{pendingCount}</span>
{/if}
</td>
</tr>
</tbody>
</table>
</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

@@ -1,36 +1,51 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation'
let { data } = $props();
let user_data = $state();
onMount(async () => {
const { data: { user } } = await data.supabase.auth.getUser();
user_data = user;
});
async function signOut() {
const { error } = await data.supabase.auth.signOut();
if (error) {
console.error('Sign out error:', error);
} else {
user_data = null; // Clear user data on sign out
await goto('/');
}
};
import type { User } from '@supabase/supabase-js';
export let data: {
user: User | null,
user_profile: any | null
};
</script>
{#if user_data}
<div class="user-profile">
<h2 class="text-2xl font-bold mb-2">Currently logged in</h2>
<p><strong>Username:</strong> {user_data.user_metadata.display_name}</p>
<p><strong>Email:</strong> {user_data.email}</p>
<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>
<button class="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 mt-4" onclick={signOut}>
Sign Out
</button>
{:else}
<p>Loading user profile...</p>
{/if}
</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

@@ -1,37 +1,132 @@
<script lang="ts">
import QRScanner from './QRScanner.svelte';
import TicketDisplay from './TicketDisplay.svelte';
import { onMount } from 'svelte';
import type { TicketData } from '$lib/types';
import { ScanState, defaultTicketData } from '$lib/types';
import type { TicketData } from '$lib/types/types';
import { ScanState, defaultTicketData } from '$lib/types/types';
let { data } = $props();
let scanned_id = $state<string>("");
let scanned_id = $state<string>('');
let ticket_data = $state<TicketData>(defaultTicketData);
let scan_state = $state<ScanState>(ScanState.scanning);
// Events related state
interface Event {
id: string;
name: string;
date: string;
}
let events = $state<Event[]>([]);
let selectedEventId = $state<string>('');
let isLoadingEvents = $state(true);
let eventsError = $state('');
onMount(async () => {
await loadEvents();
});
async function loadEvents() {
isLoadingEvents = true;
eventsError = '';
try {
const { data: eventsData, error } = await data.supabase
.from('events')
.select('id, name, date')
.order('date', { ascending: false });
if (error) throw error;
events = eventsData || [];
// If there are events, select the first one by default
if (events.length > 0) {
selectedEventId = events[0].id;
}
} catch (err) {
console.error('Error loading events:', err);
eventsError = 'Failed to load events';
} finally {
isLoadingEvents = false;
}
}
// Process a scanned QR code
$effect(() => {
if (scanned_id === "") return;
console.log('New QR code found:', scanned_id);
if (scanned_id === '') return;
scan_state = ScanState.scanning;
console.log('Scanned ID:', scanned_id);
data.supabase
.from('qrcodes')
.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;
}
})
.then((response) => {
if (response.data && response.data.length > 0) {
const participant = response.data[0];
ticket_data = participant;
// Check if the participant belongs to the selected event
if (selectedEventId && participant.event.id !== selectedEventId) {
scan_state = ScanState.wrong_event;
} else if (participant.scanned) {
scan_state = ScanState.already_scanned; // Already scanned
} else {
scan_state = ScanState.scan_successful;
data.supabase.rpc('scan_ticket', { _ticket_id: scanned_id }).then();
}
} else {
ticket_data = defaultTicketData;
scan_state = ScanState.scan_failed;
}
// Reset the scanned_id after 3 seconds to allow for a new scan
setTimeout(() => {
scanned_id = '';
}, 3000);
});
});
</script>
<QRScanner bind:message={scanned_id} />
<h1 class="text-2xl font-bold mb-4 mt-2 text-center">Code Scanner</h1>
<TicketDisplay {ticket_data} {scan_state}/>
<!-- Event Selector -->
<div class="mb-4 rounded-lg border border-gray-300 p-4">
<h2 class="mb-3 text-lg font-semibold">Select Event</h2>
{#if isLoadingEvents}
<div class="flex h-10 items-center justify-center">
<div
class="h-5 w-5 animate-spin rounded-full border-2 border-gray-500 border-t-transparent"
></div>
</div>
{:else if eventsError}
<div class="py-2 text-center text-red-600">
{eventsError}
<button onclick={loadEvents} class="ml-2 text-blue-600 underline"> Try again </button>
</div>
{:else if events.length === 0}
<p class="py-2 text-center text-gray-500">No events found</p>
{:else}
<select
bind:value={selectedEventId}
class="w-full rounded border border-gray-300 p-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{#each events as event}
<option value={event.id}>
{event.name} ({new Date(event.date).toLocaleDateString('en-GB')})
</option>
{/each}
</select>
{/if}
</div>
<!-- Scanner Section -->
<div class="mb-4">
<QRScanner bind:message={scanned_id} />
</div>
<!-- Ticket Display Section -->
<h2 class="mb-2 text-lg font-semibold">Ticket Information</h2>
<TicketDisplay {ticket_data} {scan_state} />

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import {
Html5QrcodeScanner,
type Html5QrcodeResult,
@@ -37,9 +37,15 @@
);
scanner.render(onScanSuccess, onScanFailure);
});
onDestroy(() => {
if (scanner) {
scanner.clear().catch(() => {});
}
});
</script>
<div id="qr-scanner" class="w-full h-full max-w-none bg-slate-700 overflow-hidden"></div>
<div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded-lg border border-gray-300"></div>
<style>
/* Hide unwanted icons */
@@ -49,7 +55,7 @@
}
#qr-scanner {
color: white !important;
color: black !important;
}
/* Change camera permission button text */
@@ -64,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

@@ -1,10 +1,11 @@
<script lang="ts">
import type { TicketData } from '$lib/types';
import { ScanState } from '$lib/types';
import type { TicketData } from '$lib/types/types';
import { ScanState } from '$lib/types/types';
let { ticket_data, scan_state }: { ticket_data: TicketData; scan_state: ScanState } = $props();
function formatScannedAt(dateString: string): string {
if (!dateString) return '';
const date = new Date(dateString);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -14,31 +15,73 @@
}
</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-green-500 bg-green-100 p-4 text-green-700">
<p>Scan failed. Please try again.</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>
</div>
{:else}
<div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700">
<ol>
<li>{ticket_data.event.name}</li>
<li>{ticket_data.name} {ticket_data.surname}</li>
</ol>
<div class="border border-gray-300 rounded-lg overflow-hidden h-[200px]">
<div class="h-full flex flex-col">
{#if scan_state === ScanState.scanning}
<div class="bg-gray-50 p-4 flex-1 flex flex-col justify-center items-center">
<div class="animate-spin h-8 w-8 border-2 border-gray-500 rounded-full border-t-transparent mb-3"></div>
<p class="text-gray-700 text-center">Waiting for QR code...</p>
</div>
{:else if scan_state === ScanState.scan_failed}
<div class="bg-red-50 p-4 flex-1 flex flex-col">
<div class="flex items-center gap-2 mb-3">
<svg class="h-6 w-6 text-red-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<h3 class="font-semibold text-red-800">Invalid Code</h3>
</div>
<p class="text-red-700 flex-grow">This QR code is not a valid ticket or doesn't exist in our system.</p>
<div class="mt-auto pt-2 opacity-0">
<!-- Spacer to maintain consistent height -->
<p class="font-medium invisible">Placeholder</p>
<p class="invisible">Placeholder</p>
</div>
</div>
{:else if scan_state === ScanState.wrong_event}
<div class="bg-amber-50 p-4 flex-1 flex flex-col">
<div class="flex items-center gap-2 mb-2">
<svg class="h-6 w-6 text-amber-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h3 class="font-semibold text-amber-800">Wrong Event</h3>
</div>
<p class="text-amber-700 mb-2">This ticket belongs to a different event:</p>
<div class="bg-white rounded p-3 border border-amber-200 mt-auto">
<p class="font-medium">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div>
</div>
{:else if scan_state === ScanState.already_scanned}
<div class="bg-amber-50 p-4 flex-1 flex flex-col">
<div class="flex items-center gap-2 mb-2">
<svg class="h-6 w-6 text-amber-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h3 class="font-semibold text-amber-800">Already Scanned</h3>
</div>
<p class="text-amber-700">
This ticket was already scanned by: <br/> {ticket_data.scanned_by?.display_name || 'someone'}
{ticket_data.scanned_at ? `on ${formatScannedAt(ticket_data.scanned_at)}` : ''}
</p>
<div class="bg-white rounded p-3 border border-amber-200 mt-auto">
<p class="font-medium">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div>
</div>
{:else if scan_state === ScanState.scan_successful}
<div class="bg-green-50 p-4 flex-1 flex flex-col">
<div class="flex items-center gap-2 mb-2">
<svg class="h-6 w-6 text-green-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<h3 class="font-semibold text-green-800">Valid Ticket</h3>
</div>
<p class="text-green-700">Ticket successfully validated.</p>
<div class="bg-white rounded p-3 border border-green-200 mt-auto">
<p class="font-medium">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div>
</div>
{/if}
{/if}
</div>
</div>

86
src/service-worker.ts Normal file
View File

@@ -0,0 +1,86 @@
/// <reference types="@sveltejs/kit" />
import { build, files, version } from '$service-worker';
// Create a unique cache name for this deployment
const CACHE = `cache-${version}`;
const ASSETS = [
...build, // the app itself
...files // everything in `static`
];
self.addEventListener('install', (event) => {
// Create a new cache and add all files to it
async function addFilesToCache() {
const cache = await caches.open(CACHE);
await cache.addAll(ASSETS);
}
event.waitUntil(addFilesToCache());
});
self.addEventListener('activate', (event) => {
// Remove previous cached data from disk
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key);
}
}
event.waitUntil(deleteOldCaches());
});
self.addEventListener('fetch', (event) => {
// ignore POST requests etc
if (event.request.method !== 'GET') return;
async function respond() {
const url = new URL(event.request.url);
// Skip caching for auth routes
if (url.pathname.startsWith('/auth/')) {
return fetch(event.request);
}
const cache = await caches.open(CACHE);
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
const response = await cache.match(url.pathname);
if (response) {
return response;
}
}
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
// if we're offline, fetch can return a value that is not a Response
// instead of throwing - and we can't pass this non-Response to respondWith
if (!(response instanceof Response)) {
throw new Error('invalid response from fetch');
}
if (response.status === 200) {
cache.put(event.request, response.clone());
}
return response;
} catch (err) {
const response = await cache.match(event.request);
if (response) {
return response;
}
// if there's no cache, then just error out
// as there is nothing we can do to respond to this request
throw err;
}
}
event.respondWith(respond());
});

BIN
static/qr-code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

8
supabase/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

322
supabase/config.toml Normal file
View File

@@ -0,0 +1,322 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "scan-wave"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
[edge_runtime]
enabled = true
# Configure one of the supported request policies: `oneshot`, `per_worker`.
# Use `oneshot` for hot reload, or `per_worker` for load testing.
policy = "oneshot"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 1
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"

View File

@@ -0,0 +1,827 @@
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
CREATE EXTENSION IF NOT EXISTS "pg_cron" WITH SCHEMA "pg_catalog";
COMMENT ON SCHEMA "public" IS 'standard public schema';
CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql";
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault";
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";
CREATE TYPE "public"."section_posititon" AS ENUM (
'events_manager',
'member'
);
ALTER TYPE "public"."section_posititon" OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."archive_event"("_event_id" "uuid") RETURNS "void"
LANGUAGE "plpgsql" SECURITY DEFINER
AS $$
DECLARE
v_total bigint;
v_scanned bigint;
v_evt public.events%ROWTYPE;
BEGIN
-------------------------------------------------------------------------
-- A. Fetch the event
-------------------------------------------------------------------------
SELECT * INTO v_evt
FROM public.events
WHERE id = _event_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'archive_event_and_delete(): event % does not exist', _event_id;
END IF;
-------------------------------------------------------------------------
-- B. Count participants
-------------------------------------------------------------------------
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE scanned) AS scanned
INTO v_total, v_scanned
FROM public.participants
WHERE event = _event_id;
-------------------------------------------------------------------------
-- C. Upsert into events_archived (now with section_id)
-------------------------------------------------------------------------
INSERT INTO public.events_archived (
id, created_at, date, name,
section_id, total_participants, scanned_participants )
VALUES ( v_evt.id, clock_timestamp(), v_evt.date, v_evt.name,
v_evt.section_id, v_total, v_scanned )
ON CONFLICT (id) DO UPDATE
SET created_at = EXCLUDED.created_at,
date = EXCLUDED.date,
name = EXCLUDED.name,
section_id = EXCLUDED.section_id,
total_participants = EXCLUDED.total_participants,
scanned_participants= EXCLUDED.scanned_participants;
-------------------------------------------------------------------------
-- D. Delete original event row (participants cascade away)
-------------------------------------------------------------------------
DELETE FROM public.events
WHERE id = _event_id;
END;
$$;
ALTER FUNCTION "public"."archive_event"("_event_id" "uuid") OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."auto_archive_events"("_age_days" integer DEFAULT 7) RETURNS integer
LANGUAGE "plpgsql" SECURITY DEFINER
AS $$
DECLARE
v_cnt int := 0;
v_event_id uuid;
BEGIN
FOR v_event_id IN
SELECT id
FROM public.events
WHERE date IS NOT NULL
AND date <= CURRENT_DATE - _age_days
LOOP
BEGIN
PERFORM public.archive_event(v_event_id);
v_cnt := v_cnt + 1;
EXCEPTION
WHEN others THEN
-- Optionally record the failure somewhere and continue
RAISE WARNING 'Failed to archive event %, %', v_event_id, SQLERRM;
END;
END LOOP;
RETURN v_cnt;
END;
$$;
ALTER FUNCTION "public"."auto_archive_events"("_age_days" integer) OWNER TO "postgres";
SET default_tablespace = '';
SET default_table_access_method = "heap";
CREATE TABLE IF NOT EXISTS "public"."events" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"created_by" "uuid" DEFAULT "auth"."uid"(),
"name" "text",
"date" "date",
"section_id" "uuid"
);
ALTER TABLE "public"."events" OWNER TO "postgres";
COMMENT ON TABLE "public"."events" IS 'Table of all events created';
CREATE OR REPLACE FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") RETURNS "public"."events"
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO 'public'
AS $$
declare
v_user uuid := auth.uid(); -- current user
v_section uuid; -- their section_id
v_evt public.events%rowtype; -- the inserted event
begin
-- 1) lookup the user's section
select section_id
into v_section
from public.profiles
where id = v_user;
if v_section is null then
raise exception 'no profile/section found for user %', v_user;
end if;
-- 2) insert into events, filling created_by and section_id
insert into public.events (
name,
date,
created_by,
section_id
)
values (
p_name,
p_date,
v_user,
v_section
)
returning * into v_evt;
-- 3) return the full row
return v_evt;
end;
$$;
ALTER FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."participants" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"created_by" "uuid" DEFAULT "auth"."uid"(),
"event" "uuid",
"name" "text",
"surname" "text",
"email" "text",
"scanned" boolean DEFAULT false,
"scanned_at" timestamp with time zone,
"scanned_by" "uuid",
"section_id" "uuid"
);
ALTER TABLE "public"."participants" OWNER TO "postgres";
COMMENT ON TABLE "public"."participants" IS 'Table of all qrcodes issued';
CREATE OR REPLACE FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) RETURNS SETOF "public"."participants"
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO 'public', 'pg_temp'
AS $$BEGIN
-----------------------------------------------------------------
-- 1) keep the array-length check exactly as before
-----------------------------------------------------------------
IF array_length(p_names, 1) IS DISTINCT FROM
array_length(p_surnames,1) OR
array_length(p_names, 1) IS DISTINCT FROM
array_length(p_emails, 1) THEN
RAISE EXCEPTION
'Names, surnames and emails arrays must all be the same length';
END IF;
RETURN QUERY
INSERT INTO public.participants (section_id, event, name, surname, email)
SELECT p_section_id,
p_event_id,
n, s, e
FROM unnest(p_names, p_surnames, p_emails) AS u(n, s, e)
RETURNING *;
END;$$;
ALTER FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."handle_new_user"() RETURNS "trigger"
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO 'public', 'auth'
AS $$begin
insert into public.profiles(id, display_name, created_at, updated_at)
values (new.id,
coalesce(new.raw_user_meta_data ->> 'display_name', -- meta-data name if present
split_part(new.email, '@', 1)), -- fallback: part of the email
now(), now());
return new;
end;$$;
ALTER FUNCTION "public"."handle_new_user"() OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") RETURNS "void"
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO 'public'
AS $$BEGIN
UPDATE participants
SET scanned = true,
scanned_at = NOW(),
scanned_by = auth.uid()
WHERE id = _ticket_id;
-- optionally, make sure exactly one row was updated
IF NOT FOUND THEN
RAISE EXCEPTION 'Ticket % not found or already scanned', _ticket_id;
END IF;
END;$$;
ALTER FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."events_archived" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"date" "date",
"name" "text" NOT NULL,
"total_participants" numeric,
"scanned_participants" numeric,
"section_id" "uuid"
);
ALTER TABLE "public"."events_archived" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."profiles" (
"id" "uuid" NOT NULL,
"display_name" "text",
"created_at" timestamp with time zone DEFAULT "now"(),
"updated_at" timestamp with time zone DEFAULT "now"(),
"section_id" "uuid",
"section_position" "public"."section_posititon" DEFAULT 'member'::"public"."section_posititon" NOT NULL
);
ALTER TABLE "public"."profiles" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."sections" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"name" "text" NOT NULL
);
ALTER TABLE "public"."sections" OWNER TO "postgres";
COMMENT ON TABLE "public"."sections" IS 'List of ESN sections using the app';
ALTER TABLE ONLY "public"."events_archived"
ADD CONSTRAINT "events_archived_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."events"
ADD CONSTRAINT "events_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."profiles"
ADD CONSTRAINT "profiles_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."participants"
ADD CONSTRAINT "qrcodes_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."sections"
ADD CONSTRAINT "sections_name_key" UNIQUE ("name");
ALTER TABLE ONLY "public"."sections"
ADD CONSTRAINT "sections_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."events_archived"
ADD CONSTRAINT "events_archived_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."events"
ADD CONSTRAINT "events_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id");
ALTER TABLE ONLY "public"."events"
ADD CONSTRAINT "events_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."participants"
ADD CONSTRAINT "participants_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."participants"
ADD CONSTRAINT "participants_event_fkey" FOREIGN KEY ("event") REFERENCES "public"."events"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."participants"
ADD CONSTRAINT "participants_scanned_by_fkey" FOREIGN KEY ("scanned_by") REFERENCES "public"."profiles"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."profiles"
ADD CONSTRAINT "profiles_id_fkey" FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."profiles"
ADD CONSTRAINT "profiles_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."participants"
ADD CONSTRAINT "qrcodes_scanned_by_fkey" FOREIGN KEY ("scanned_by") REFERENCES "auth"."users"("id");
ALTER TABLE ONLY "public"."participants"
ADD CONSTRAINT "qrcodes_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE CASCADE;
CREATE POLICY "Access only to section resources" ON "public"."events_archived" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1
FROM "public"."profiles" "p"
WHERE ("p"."section_id" = "events_archived"."section_id"))));
CREATE POLICY "Enable select for authenticated users only" ON "public"."profiles" FOR SELECT TO "authenticated" USING (true);
CREATE POLICY "Enable select for authenticated users only" ON "public"."sections" FOR SELECT TO "authenticated" USING (true);
CREATE POLICY "Only display section resources" ON "public"."events" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1
FROM "public"."profiles" "p"
WHERE ("p"."section_id" = "events"."section_id"))));
CREATE POLICY "Only display section resources" ON "public"."participants" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1
FROM "public"."profiles" "p"
WHERE ("p"."section_id" = "participants"."section_id"))));
ALTER TABLE "public"."events" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."events_archived" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."participants" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."profiles" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."sections" ENABLE ROW LEVEL SECURITY;
ALTER PUBLICATION "supabase_realtime" OWNER TO "postgres";
GRANT USAGE ON SCHEMA "public" TO "postgres";
GRANT USAGE ON SCHEMA "public" TO "anon";
GRANT USAGE ON SCHEMA "public" TO "authenticated";
GRANT USAGE ON SCHEMA "public" TO "service_role";
GRANT ALL ON FUNCTION "public"."archive_event"("_event_id" "uuid") TO "anon";
GRANT ALL ON FUNCTION "public"."archive_event"("_event_id" "uuid") TO "authenticated";
GRANT ALL ON FUNCTION "public"."archive_event"("_event_id" "uuid") TO "service_role";
GRANT ALL ON FUNCTION "public"."auto_archive_events"("_age_days" integer) TO "anon";
GRANT ALL ON FUNCTION "public"."auto_archive_events"("_age_days" integer) TO "authenticated";
GRANT ALL ON FUNCTION "public"."auto_archive_events"("_age_days" integer) TO "service_role";
GRANT ALL ON TABLE "public"."events" TO "anon";
GRANT ALL ON TABLE "public"."events" TO "authenticated";
GRANT ALL ON TABLE "public"."events" TO "service_role";
GRANT ALL ON FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") TO "anon";
GRANT ALL ON FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") TO "authenticated";
GRANT ALL ON FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") TO "service_role";
GRANT ALL ON TABLE "public"."participants" TO "anon";
GRANT ALL ON TABLE "public"."participants" TO "authenticated";
GRANT ALL ON TABLE "public"."participants" TO "service_role";
GRANT ALL ON FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) TO "anon";
GRANT ALL ON FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) TO "authenticated";
GRANT ALL ON FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) TO "service_role";
GRANT ALL ON FUNCTION "public"."handle_new_user"() TO "anon";
GRANT ALL ON FUNCTION "public"."handle_new_user"() TO "authenticated";
GRANT ALL ON FUNCTION "public"."handle_new_user"() TO "service_role";
GRANT ALL ON FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") TO "anon";
GRANT ALL ON FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") TO "authenticated";
GRANT ALL ON FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") TO "service_role";
GRANT ALL ON TABLE "public"."events_archived" TO "anon";
GRANT ALL ON TABLE "public"."events_archived" TO "authenticated";
GRANT ALL ON TABLE "public"."events_archived" TO "service_role";
GRANT ALL ON TABLE "public"."profiles" TO "anon";
GRANT ALL ON TABLE "public"."profiles" TO "authenticated";
GRANT ALL ON TABLE "public"."profiles" TO "service_role";
GRANT ALL ON TABLE "public"."sections" TO "anon";
GRANT ALL ON TABLE "public"."sections" TO "authenticated";
GRANT ALL ON TABLE "public"."sections" TO "service_role";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "anon";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "authenticated";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role";
RESET ALL;

View File

@@ -0,0 +1,34 @@
revoke select on table "auth"."schema_migrations" from "postgres";
CREATE TRIGGER on_auth_users_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user();
grant delete on table "storage"."s3_multipart_uploads" to "postgres";
grant insert on table "storage"."s3_multipart_uploads" to "postgres";
grant references on table "storage"."s3_multipart_uploads" to "postgres";
grant select on table "storage"."s3_multipart_uploads" to "postgres";
grant trigger on table "storage"."s3_multipart_uploads" to "postgres";
grant truncate on table "storage"."s3_multipart_uploads" to "postgres";
grant update on table "storage"."s3_multipart_uploads" to "postgres";
grant delete on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant insert on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant references on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant select on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant trigger on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant truncate on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant update on table "storage"."s3_multipart_uploads_parts" to "postgres";

View File

@@ -0,0 +1,90 @@
drop function if exists "public"."create_event"(p_name text, p_date date, p_email_subject text, p_email_body text, p_sheet_id text, p_name_column integer, p_surname_column integer, p_email_column integer, p_confirmation_column integer);
drop function if exists "public"."participant_emailed"(p_participant_id uuid, p_sent boolean);
drop function if exists "public"."participants_add_bulk"(p_event uuid, p_names text[], p_surnames text[], p_emails text[]);
drop index if exists "public"."participants_event_name_surname_email_uidx";
alter table "public"."events" drop column "confirmation_column";
alter table "public"."events" drop column "email_column";
alter table "public"."events" drop column "name_column";
alter table "public"."events" drop column "surname_column";
alter table "public"."participants" drop column "email_sent";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.create_event(p_name text, p_date date)
RETURNS events
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
declare
v_user uuid := auth.uid(); -- current user
v_section uuid; -- their section_id
v_evt public.events%rowtype; -- the inserted event
begin
-- 1) lookup the user's section
select section_id
into v_section
from public.profiles
where id = v_user;
if v_section is null then
raise exception 'no profile/section found for user %', v_user;
end if;
-- 2) insert into events, filling created_by and section_id
insert into public.events (
name,
date,
created_by,
section_id
)
values (
p_name,
p_date,
v_user,
v_section
)
returning * into v_evt;
-- 3) return the full row
return v_evt;
end;
$function$
;
CREATE OR REPLACE FUNCTION public.create_qrcodes_bulk(p_section_id uuid, p_event_id uuid, p_names text[], p_surnames text[], p_emails text[])
RETURNS SETOF participants
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$BEGIN
-----------------------------------------------------------------
-- 1) keep the array-length check exactly as before
-----------------------------------------------------------------
IF array_length(p_names, 1) IS DISTINCT FROM
array_length(p_surnames,1) OR
array_length(p_names, 1) IS DISTINCT FROM
array_length(p_emails, 1) THEN
RAISE EXCEPTION
'Names, surnames and emails arrays must all be the same length';
END IF;
RETURN QUERY
INSERT INTO public.participants (section_id, event, name, surname, email)
SELECT p_section_id,
p_event_id,
n, s, e
FROM unnest(p_names, p_surnames, p_emails) AS u(n, s, e)
RETURNING *;
END;$function$
;

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()]
});