99 Commits

Author SHA1 Message Date
cdc8b89916 Merge pull request 'Added loading indicator' (#24) from development into main
All checks were successful
Build Docker image / build (push) Successful in 3m19s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 1m57s
Reviewed-on: #24
2025-09-02 18:26:28 +02:00
9dd79514f5 Merge branch 'main' into development 2025-09-02 18:21:55 +02:00
Roman Krček
cd37d8da0f Added loading indicator 2025-09-02 18:18:58 +02:00
eb0276e475 Dummy change to trigger actions 2025-09-02 18:00:47 +02:00
183854effd Merge pull request 'Add ability to search shared drives' (#22) from development into main
Reviewed-on: #22
2025-09-02 17:57:49 +02:00
Roman Krček
0fa20dffa5 Add ability to search shared drives 2025-09-02 17:56:31 +02:00
438f7299b4 Merge pull request 'CSS Styling' (#21) from development into main
Some checks failed
Build Docker image / build (push) Successful in 3m41s
Build Docker image / deploy (push) Has been cancelled
Build Docker image / verify (push) Has been cancelled
Reviewed-on: #21
2025-07-15 11:23:47 +02:00
Roman Krček
f4146e599b CSS Styling 2025-07-15 11:23:10 +02:00
dc6602a904 Merge pull request 'development' (#20) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m15s
Build Docker image / deploy (push) Successful in 8s
Build Docker image / verify (push) Successful in 37s
Reviewed-on: #20
2025-07-14 22:28:06 +02:00
Roman Krček
eb9fa14d28 Remember last event selected in scanner 2025-07-14 22:27:18 +02:00
Roman Krček
30f441a956 Styling and minor changes 2025-07-14 22:27:00 +02:00
Roman Krček
5b26b6951c Icons and better auth flow 2025-07-14 22:16:03 +02:00
fa2185a6a1 Merge pull request 'development' (#19) from development into main
All checks were successful
Build Docker image / build (push) Successful in 5m50s
Build Docker image / deploy (push) Successful in 7s
Build Docker image / verify (push) Successful in 1m22s
Reviewed-on: #19
2025-07-14 21:40:16 +02:00
Roman Krček
ffbd3c7cda Home styling 2025-07-14 21:39:49 +02:00
Roman Krček
5d957b18ee More notifications in participants table 2025-07-14 21:37:05 +02:00
Roman Krček
396d29c76b Make emails editable 2025-07-14 21:25:57 +02:00
Roman Krček
d0f555a7c5 Minor styling changes 2025-07-14 16:05:29 +02:00
Roman Krček
f14213a5d4 Add role base access control for events module 2025-07-14 15:50:07 +02:00
Roman Krček
6466665549 Redirect now directly to the event 2025-07-14 14:56:49 +02:00
Roman Krček
b9db3d22a3 Cleanup for error notifications 2025-07-14 14:34:38 +02:00
Roman Krček
06f2553b42 Better error norifications 2025-07-14 14:30:55 +02:00
b0e530ed62 Merge pull request 'development' (#18) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m40s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 25s
Reviewed-on: #18
2025-07-12 22:01:27 +02:00
Roman Krček
a8f1b973e6 Templating for names and surnames 2025-07-12 22:01:05 +02:00
Roman Krček
308e70941f Use tanstack for caching of events 2025-07-12 21:25:04 +02:00
Roman Krček
5a09b50e82 Get rid of leftover setup 2025-07-12 18:58:16 +02:00
c18a67e926 Merge pull request 'development' (#17) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m5s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 28s
Reviewed-on: #17
2025-07-12 15:44:07 +02:00
Roman Krček
a11bd416bf Add caching to service worker 2025-07-12 15:43:36 +02:00
Roman Krček
5751c6d6dc Fixed layout problems 2025-07-12 15:14:59 +02:00
45fa8b3005 Merge pull request 'supabase-local' (#16) from supabase-local into main
All checks were successful
Build Docker image / build (push) Successful in 1m12s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 25s
Reviewed-on: #16
2025-07-12 15:04:28 +02:00
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
c97acffe5b Merge pull request 'supabase-local' (#15) from supabase-local into main
All checks were successful
Build Docker image / build (push) Successful in 3m34s
Build Docker image / deploy (push) Successful in 7s
Build Docker image / verify (push) Successful in 42s
Reviewed-on: #15
2025-07-08 17:34:14 +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
99f2b778e5 Merge pull request 'development' (#14) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m47s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 28s
Reviewed-on: #14
2025-06-29 17:32:26 +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
0a556f144c Merge pull request 'development' (#13) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m9s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 26s
Reviewed-on: #13
2025-06-29 17:17:20 +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
9c99a88bb0 Merge pull request 'Fix build issues' (#12) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m54s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 24s
Reviewed-on: #12
2025-06-28 00:40:36 +02:00
Roman Krček
ae9cedf51c Fix build issues 2025-06-28 00:40:14 +02:00
15d2426ce6 Merge pull request 'Fix mixing old and new syntaxes error' (#11) from development into main
Some checks failed
Build Docker image / build (push) Failing after 40s
Build Docker image / deploy (push) Has been skipped
Build Docker image / verify (push) Has been skipped
Reviewed-on: #11
2025-06-28 00:36:08 +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
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
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
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
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
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
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
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
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
95 changed files with 7042 additions and 1183 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:
@@ -48,6 +48,26 @@ jobs:
org.opencontainers.image.ref.name=${{ env.GITHUB_REF }}
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:

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

@@ -0,0 +1,184 @@
GitHub Copilot Instructions for This Repository
Basics: These you need to really follow!
- 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.
- Never use supabse-js. I am using supabse-ssr and supabase client is located in:
- client: $props.data.supabse
- server: $locals.supabase
- Avoid unnceessary iterations. Once the problem is solved, ask me if i want to to continue and only then continue iterating.
- Avoid sweeping changes throught the project. If you want to change something globally, ask me first.
- to add a notification, use the toast component
- example: toast.success, toast.info, toast.warning, toast.error
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)
);

View File

@@ -9,4 +9,4 @@ services:
- "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"
- "traefik.http.routers.scan-wave.middlewares=hsts"

149
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12",
"@tanstack/svelte-query": "^5.83.0",
"googleapis": "^150.0.1",
"papaparse": "^5.5.3",
"qrcode": "^1.5.4",
@@ -26,6 +27,7 @@
"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",
@@ -1381,6 +1383,32 @@
"vite": "^5.2.0 || ^6"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz",
"integrity": "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/svelte-query": {
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-5.83.0.tgz",
"integrity": "sha512-8tNXhuoizntZXnAzo4yqUWgZZnklQkXGUNpb3YreW68DyCBhhrGbErnrODQs3fVc2ABcMvAHIki5uErbdzXH1A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.83.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"svelte": "^3.54.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -1515,6 +1543,23 @@
"node": "*"
}
},
"node_modules/bin-links": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-5.0.0.tgz",
"integrity": "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==",
"dev": true,
"license": "ISC",
"dependencies": {
"cmd-shim": "^7.0.0",
"npm-normalize-package-bin": "^4.0.0",
"proc-log": "^5.0.0",
"read-cmd-shim": "^5.0.0",
"write-file-atomic": "^6.0.0"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -1605,6 +1650,16 @@
"node": ">=6"
}
},
"node_modules/cmd-shim": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-7.0.0.tgz",
"integrity": "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2149,6 +2204,16 @@
"node": ">= 14"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -2643,6 +2708,16 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/npm-normalize-package-bin": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
"integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2888,6 +2963,16 @@
}
}
},
"node_modules/proc-log": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
"integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -2932,6 +3017,16 @@
"quoted-printable": "bin/quoted-printable"
}
},
"node_modules/read-cmd-shim": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz",
"integrity": "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3136,6 +3231,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-icons": {
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-15.3.0.tgz",
@@ -3204,6 +3312,26 @@
"node": ">=8"
}
},
"node_modules/supabase": {
"version": "2.30.4",
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.30.4.tgz",
"integrity": "sha512-AOCyd2vmBBMTXbnahiCU0reRNxKS4n5CrPciUF2tcTrQ8dLzl1HwcLfe5DrG8E0QRcKHPDdzprmh/2+y4Ta5MA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bin-links": "^5.0.0",
"https-proxy-agent": "^7.0.2",
"node-fetch": "^3.3.2",
"tar": "7.4.3"
},
"bin": {
"supabase": "bin/supabase"
},
"engines": {
"npm": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3265,13 +3393,6 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte-kit": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/svelte-kit/-/svelte-kit-1.2.0.tgz",
"integrity": "sha512-RRaOHBhpDv4g2v9tcq8iNw055Pt0MlLps6JVA7/40f4KAbtztXSI4T6MZYbHRirO708urfAAMx6Qow+tQfCHug==",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
@@ -3551,6 +3672,20 @@
"node": ">=8"
}
},
"node_modules/write-file-atomic": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz",
"integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",

View File

@@ -22,6 +22,7 @@
"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",
@@ -32,6 +33,7 @@
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12",
"@tanstack/svelte-query": "^5.83.0",
"googleapis": "^150.0.1",
"papaparse": "^5.5.3",
"qrcode": "^1.5.4",

13
src/app.d.ts vendored
View File

@@ -1,17 +1,30 @@
import type { Session, SupabaseClient, User } from '@supabase/supabase-js'
import type { Database } from './database.types.ts' // import generated types
// Define the profile type based on the database schema
type Profile = {
display_name: string | null
section_position: string | null
section: {
name: string | null
} | null
}
declare global {
namespace App {
// interface Error {}
interface Locals {
supabase: SupabaseClient<Database>
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>
getUserProfile: (userId: string) => Promise<Profile | null>
session: Session | null
user: User | null
profile: Profile | null
}
interface PageData {
session: Session | null
user: User | null
profile: Profile | null
}
// interface PageState {}
// interface Platform {}

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
@@ -16,5 +16,4 @@
body {
font-family: "Roboto", sans-serif;
}
</style>

View File

@@ -51,6 +51,22 @@ const supabase: Handle = async ({ event, resolve }) => {
return { session, user }
}
/**
* Fetch user profile data including display name, section position, and section name
*/
event.locals.getUserProfile = async (userId) => {
if (!userId) return null
const { data: profile, error } = await event.locals.supabase
.from('profiles')
.select('display_name, section_position, section:sections (name)')
.eq('id', userId)
.single()
if (error) return null
return profile
}
return resolve(event, {
filterSerializedResponseHeaders(name) {
/**
@@ -67,14 +83,26 @@ const authGuard: Handle = async ({ event, resolve }) => {
event.locals.session = session
event.locals.user = user
// Fetch the user's profile if they're authenticated
if (user) {
event.locals.profile = await event.locals.getUserProfile(user.id)
}
if (!event.locals.session && event.url.pathname.startsWith('/private')) {
redirect(303, '/auth')
redirect(303, '/auth/login')
}
if (event.locals.session && event.url.pathname === '/auth') {
redirect(303, '/private/home')
}
// Role-based access control for events routes
if (event.url.pathname.startsWith('/private/events')) {
if (!event.locals.profile || event.locals.profile.section_position !== 'events_manager') {
redirect(303, '/private/errors/events/denied')
}
}
return resolve(event)
}

View File

@@ -0,0 +1,124 @@
<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(async () => {
await 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.checking}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 border border-gray-300 whitespace-nowrap">
<div class="w-4 h-4 animate-spin rounded-full border-2 border-current border-t-transparent text-gray-600"></div>
<span class="text-sm font-medium text-gray-800">Checking connection...</span>
</div>
</div>
{:else 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,25 @@
<script lang="ts">
import { toast } from '$lib/stores/toast';
import ToastNotification from './ToastNotification.svelte';
// Subscribe to the toast store using Svelte 5 reactivity
let toasts = $derived($toast);
function handleDismiss(id: string) {
toast.remove(id);
}
</script>
<!-- Toast container positioned in top-left corner -->
<div class="fixed top-4 p-2 space-y-3 pointer-events-none max-w-2xl">
{#each toasts as toastItem (toastItem.id)}
<div class="pointer-events-auto">
<ToastNotification
message={toastItem.message}
type={toastItem.type}
duration={toastItem.duration}
onDismiss={() => handleDismiss(toastItem.id)}
/>
</div>
{/each}
</div>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
message,
type = 'error',
duration = 5000,
onDismiss
} = $props<{
message: string;
type?: 'error' | 'success' | 'warning' | 'info';
duration?: number;
onDismiss?: () => void;
}>();
let visible = $state(true);
let timeoutId: ReturnType<typeof setTimeout>;
// Auto-dismiss after specified duration
onMount(() => {
if (duration > 0) {
timeoutId = setTimeout(() => {
dismiss();
}, duration);
}
// Cleanup timeout on component destroy
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function dismiss() {
visible = false;
if (onDismiss) {
onDismiss();
}
}
// Get styles based on toast type
const getToastStyles = (type: string) => {
switch (type) {
case 'success':
return 'border-green-200 bg-green-50 text-green-800';
case 'warning':
return 'border-yellow-200 bg-yellow-50 text-yellow-800';
case 'info':
return 'border-blue-200 bg-blue-50 text-blue-800';
case 'error':
default:
return 'border-red-200 bg-red-50 text-red-800';
}
};
// Get icon SVG path based on toast type
const getIconSvg = (type: string) => {
switch (type) {
case 'success':
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />`;
case 'warning':
return `<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-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.268 16c-.77.833.192 2.5 1.732 2.5z" />`;
case 'info':
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />`;
case 'error':
default:
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />`;
}
};
</script>
{#if visible && message}
<div
class="rounded-lg border p-4 shadow-lg w-full {getToastStyles(type)}"
role="alert"
aria-live="polite"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="flex-shrink-0">
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
{@html getIconSvg(type)}
</svg>
</div>
<!-- Message -->
<div class="flex-1">
<p class="text-sm font-medium">
{message}
</p>
</div>
<!-- Close button -->
<button
onclick={dismiss}
class="flex-shrink-0 ml-2 text-current opacity-70 hover:opacity-100 transition-opacity"
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Toast has no progress bar as requested -->
</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,147 @@
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;
}
async checkConnection(): Promise<void> {
this.state.checking = true;
this.state.error = null;
try {
const token = localStorage.getItem('google_refresh_token');
const email = localStorage.getItem('google_user_email');
if (!token) {
this.state.isConnected = false;
this.state.token = null;
this.state.userEmail = null;
return;
}
// Verify the token by calling our backend endpoint
const response = await fetch('/private/api/google/auth/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken: token })
});
if (response.ok) {
this.state.isConnected = true;
this.state.token = token;
this.state.userEmail = email;
} else {
// Token is invalid or expired
await this.disconnectGoogle();
if (response.status === 401) {
this.state.error = 'Google session expired. Please reconnect.';
} else {
this.state.error = 'Failed to verify connection.';
}
}
} catch (error) {
console.error('Error checking connection:', error);
this.state.error = 'Failed to verify connection status';
this.state.isConnected = false;
} 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

@@ -1,42 +1,14 @@
import { google } from 'googleapis';
import { env } from '$env/dynamic/private';
import quotedPrintable from 'quoted-printable'; // tiny, zero-dep package
import quotedPrintable from 'quoted-printable';
import { getOAuthClient } from '../auth/server.js';
export const scopes = ['https://www.googleapis.com/auth/gmail.send'];
export function getOAuthClient() {
return new google.auth.OAuth2(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
env.GOOGLE_REDIRECT_URI
);
}
export function createAuthUrl() {
return getOAuthClient().generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: scopes
});
}
export async function exchangeCodeForTokens(code: string) {
const { tokens } = await getOAuthClient().getToken(code);
if (!tokens.refresh_token) throw new Error('No refresh_token returned');
return tokens.refresh_token;
}
export async function sendGmail(
refreshToken: string,
{ to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string }
) {
const oauth = getOAuthClient();
oauth.setCredentials({ refresh_token: refreshToken });
const gmail = google.gmail({ version: 'v1', auth: oauth });
const message_html =
`<!DOCTYPE html>
/**
* 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>
@@ -55,17 +27,36 @@ export async function sendGmail(
<div style="height: 4px; width: 20%; background: #2e3192;"></div>
</div>
<div style="font-size: 12px; color: #999; padding-top: 0px; margin-top: 10px; line-height: 1.5; ">
<p>This email has been generated with the help of *insert software name*</p>
<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
const htmlQP = quotedPrintable.encode(message_html);
// 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 = [

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,131 @@
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)',
includeItemsFromAllDrives: true,
supportsAllDrives: true
});
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)',
includeItemsFromAllDrives: true,
supportsAllDrives: true
});
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[][];
}

9
src/lib/helpers.ts Normal file
View File

@@ -0,0 +1,9 @@
export const reactiveQueryArgs = <T>(cb: () => T) => {
const store = writable<T>();
$effect.pre(() => {
store.set(cb());
});
return store;
};

View File

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

63
src/lib/stores/toast.ts Normal file
View File

@@ -0,0 +1,63 @@
import { writable } from 'svelte/store';
export interface Toast {
id: string;
message: string;
type: 'error' | 'success' | 'warning' | 'info';
duration?: number;
}
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
const store = {
subscribe,
// Add a new toast
add: (toast: Omit<Toast, 'id'>) => {
const id = crypto.randomUUID();
const newToast: Toast = {
id,
duration: 5000, // Default 5 seconds
...toast
};
update(toasts => [...toasts, newToast]);
return id;
},
// Remove a toast by ID
remove: (id: string) => {
update(toasts => toasts.filter(toast => toast.id !== id));
},
// Clear all toasts
clear: () => {
update(() => []);
}
};
// Add convenience methods that reference the same store instance
return {
...store,
// Convenience methods for different toast types
success: (message: string, duration?: number) => {
return store.add({ message, type: 'success', duration });
},
error: (message: string, duration?: number) => {
return store.add({ message, type: 'error', duration });
},
warning: (message: string, duration?: number) => {
return store.add({ message, type: 'warning', duration });
},
info: (message: string, duration?: number) => {
return store.add({ message, type: 'info', duration });
}
};
}
export const toast = createToastStore();

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,9 +1,18 @@
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
const { session } = await safeGetSession()
export const load: LayoutServerLoad = async ({ locals: { safeGetSession, getUserProfile }, cookies }) => {
const { session, user } = await safeGetSession()
// Get the user profile if the user is authenticated
let profile = null
if (user) {
profile = await getUserProfile(user.id)
}
return {
session,
user,
profile,
cookies: cookies.getAll(),
}
}

View File

@@ -39,5 +39,10 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => {
data: { user },
} = await supabase.auth.getUser()
return { session, supabase, user }
return {
session,
supabase,
user,
profile: data.profile
}
}

View File

@@ -1,5 +1,4 @@
<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>

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,21 +1,44 @@
<script lang="ts">
// Add any navbar logic here if needed
import { browser } from '$app/environment';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import ToastContainer from '$lib/components/ToastContainer.svelte';
let { data, children } = $props();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
staleTime: 5 * 60_000, // 5 min cache
refetchOnWindowFocus: false
}
}
});
</script>
<nav class="bg-gray-50 border-b border-gray-300 text-gray-900 p-2">
<div class="container max-w-2xl mx-auto p-2">
<div class="flex items-center justify-between">
<div class="font-bold text-lg">ScanWave</div>
<ul class="flex space-x-4">
<li><a href="/private/home" class="hover:underline">Home</a></li>
<li><a href="/private/scanner" class="hover:underline">Scanner</a></li>
<li><a href="/private/events" class="hover:underline">Events</a></li>
</ul>
</div>
</div>
<nav class="border-b border-gray-300 bg-gray-50 p-2 text-gray-900">
<div class="container mx-auto max-w-2xl p-2">
<div class="flex items-center justify-between">
<a href="/private/home" class="text-lg font-bold" aria-label="ScanWave Home">ScanWave</a>
<ul class="flex space-x-4">
<li><a href="/private/scanner">Scanner</a></li>
{#if data.profile?.section_position === 'events_manager'}
<li><a href="/private/events">Events</a></li>
{/if}
</ul>
</div>
</div>
</nav>
<div class="container max-w-2xl mx-auto p-2 bg-white">
<slot />
<div class="container mx-auto max-w-2xl bg-white p-2">
<QueryClientProvider client={queryClient}>
{@render children()}
</QueryClientProvider>
</div>
<ToastContainer />

View File

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

View File

@@ -0,0 +1,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,32 @@
import { json } from '@sveltejs/kit';
import { getAuthenticatedClient } from '$lib/google/auth/server';
/**
* @description Verify the validity of a Google refresh token
* @method POST
* @param {Request} request
* @returns {Response}
*/
export async function POST({ request }: { request: Request }): Promise<Response> {
try {
const { refreshToken } = await request.json();
if (!refreshToken) {
return json({ error: 'Refresh token is required' }, { status: 400 });
}
// Get an authenticated client. This will attempt to get a new access token,
// which effectively validates the refresh token.
const oauth2Client = getAuthenticatedClient(refreshToken);
// Attempt to get a new access token
await oauth2Client.getAccessToken();
// If no error is thrown, the token is valid
return json({ success: true });
} catch (error) {
console.error('Failed to verify Google refresh token:', error);
// The token is likely invalid or revoked
return json({ error: 'Invalid or expired refresh token' }, { status: 401 });
}
}

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,168 @@
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;
}
/**
* Replaces template variables in a string with participant data
* Currently supports {name} and {surname} placeholders
*/
function replaceTemplateVariables(template: string, participant: Participant): string {
return template
.replace(/{name}/gi, participant.name || '')
.replace(/{surname}/gi, participant.surname || '');
}
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);
// Replace template variables in subject and body
const personalizedSubject = replaceTemplateVariables(subject, participant);
const personalizedText = replaceTemplateVariables(text, participant);
// Send email with QR code
await sendGmail(refreshToken, {
to: participant.email,
subject: personalizedSubject,
text: personalizedText,
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
<script lang="ts">
// Get the profile from the page data if available
let { data } = $props();
let profile = $derived(data.profile);
</script>
<div class="flex flex-col items-center justify-center min-h-[70vh] p-6">
<div class="rounded-lg border border-gray-300 p-6 max-w-md w-full flex flex-col gap-6 text-center">
<div class="flex flex-col items-center gap-2">
<div class="text-red-600 bg-red-50 p-3 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<h1 class="text-2xl font-semibold text-gray-800">Access Denied</h1>
<p class="text-gray-600">You don't have permission to access the events section.</p>
</div>
<div class="flex flex-col gap-4">
{#if profile}
<p class="text-sm text-gray-500">
Your current role: <span class="font-semibold">{profile.section_position || 'Not assigned'}</span>
</p>
{#if profile.section}
<p class="text-sm text-gray-500">
Section: <span class="font-semibold">{profile.section.name}</span>
</p>
{/if}
{/if}
<p class="text-gray-600">
You need the <span class="font-semibold">events_manager</span> role to access this section.
Please contact your administrator for assistance.
</p>
</div>
<div class="flex flex-col gap-3">
<a href="/private/home" class="rounded-md px-4 py-2 bg-blue-600 text-white">
Go to Dashboard
</a>
<button
onclick={() => window.history.back()}
class="rounded-md px-4 py-2 border border-gray-300 text-gray-700"
aria-label="Go back"
>
Go Back
</button>
</div>
</div>
</div>

View File

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

View File

@@ -1,25 +1,188 @@
<script lang="ts">
export let data;
import SingleEvent from './SingleEvent.svelte';
import { createQuery } from '@tanstack/svelte-query';
import { getEvents } from './queries';
import { writable } from 'svelte/store';
// Get Supabase client from props
let { data } = $props();
// Reactive state for search input and debounced search term
let searchInput = $state('');
let debouncedSearch = writable('');
let searchTimeout: ReturnType<typeof setTimeout>;
// Debounce the search input
function handleSearchInput() {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
debouncedSearch.set(searchInput);
}, 300);
}
const eventsQuery = $derived(
createQuery({
queryKey: ['refetch', $debouncedSearch],
queryFn: async () => getEvents(data.supabase, $debouncedSearch)
})
);
// Derived values for UI state
let isLoading = $derived($eventsQuery.isLoading || $eventsQuery.isFetching);
let displayEvents = $derived($eventsQuery.data || []);
let hasError = $derived(!!$eventsQuery.error);
let errorMessage = $derived($eventsQuery.error?.message);
// Format date helper function
function formatDate(dateString: string) {
const date = new Date(dateString);
return date.toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
// Clear search function
function clearSearch() {
searchInput = '';
debouncedSearch.set('');
}
</script>
<h1 class="text-2xl font-bold mb-4 mt-2 text-center">All Events</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
{#each data.events as event}
<a
href={`/private/events/event?id=${event.id}`}
class="block border border-gray-300 rounded bg-white p-4 shadow-none transition cursor-pointer hover:border-blue-500 group"
>
<div class="flex flex-col gap-1">
<span class="font-semibold text-lg text-black-700 group-hover:underline">{event.name}</span>
<span class="text-gray-500 text-sm">{event.date}</span>
</div>
</a>
{/each}
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">
{$debouncedSearch ? `Search Results: "${$debouncedSearch}"` : 'All Events'}
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto mb-10">
{#if isLoading}
<!-- 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 hasError}
<div class="col-span-full text-center py-8">
<p class="text-red-600">{errorMessage}</p>
</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>
<a
href="/private/creator"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-full shadow-none border border-gray-300"
>
New Event
</a>
<!-- Bottom actions - Mobile optimized -->
<div class="pb-safe fixed right-0 bottom-0 left-0 z-50 border-t border-gray-300 bg-white">
<!-- Search bar and New Event button layout -->
<div class="mx-auto flex max-w-2xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center">
<!-- Search bar - Full width on mobile, adaptive on desktop -->
<div class="relative flex-grow">
<input
type="text"
bind:value={searchInput}
oninput={handleSearchInput}
placeholder="Search events..."
class="w-full rounded-lg border border-gray-300 py-2.5 pr-10 pl-10 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<div class="absolute top-1/2 left-3 -translate-y-1/2">
{#if isLoading}
<svg
class="h-4 w-4 animate-spin 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 searchInput}
<button
onclick={clearSearch}
class="absolute top-1/2 right-3 -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="rounded-lg bg-blue-600 px-6 py-2.5 text-center font-bold whitespace-nowrap text-white transition hover:bg-blue-700 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

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

View File

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

View File

@@ -0,0 +1,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,493 @@
<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 { toast } from '$lib/stores/toast.js';
// 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 {
// Clear previous errors
errors = {};
let isValid = true;
if (currentStep === 0) {
if (!authData.isConnected) {
toast.error('Please connect your Google account to continue');
errors.auth = 'Please connect your Google account to continue';
return false;
}
} else if (currentStep === 1) {
if (!eventData.name.trim()) {
toast.error('Event name is required');
errors.name = 'Event name is required';
isValid = false;
}
if (!eventData.date) {
toast.error('Event date is required');
errors.date = 'Event date is required';
isValid = false;
}
} else if (currentStep === 2) {
if (!sheetsData.selectedSheet) {
toast.error('Please select a Google Sheet');
errors.sheet = 'Please select a Google Sheet';
isValid = false;
}
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) {
const errorMsg = `Please map the following columns: ${missingColumns.join(', ')}`;
toast.error(errorMsg);
errors.sheetData = errorMsg;
isValid = false;
}
}
} else if (currentStep === 3) {
if (!emailData.subject.trim()) {
toast.error('Email subject is required');
errors.subject = 'Email subject is required';
isValid = false;
}
if (!emailData.body.trim()) {
toast.error('Email body is required');
errors.body = 'Email body is required';
isValid = false;
}
}
return isValid;
}
// 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;
}
// Reset sheet selection and show sheet list
function resetSheetSelection() {
sheetsData.selectedSheet = null;
sheetsData.sheetData = [];
sheetsData.columnMapping = {
name: 0,
surname: 0,
email: 0,
confirmation: 0
};
sheetsData.expandedSheetList = true;
}
// Final submission
async function createEvent() {
if (!validateCurrentStep()) return;
loading = true;
try {
const { data: newEvent, 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;
// Display success message
toast.success(`Event "${eventData.name}" was created successfully`);
// Redirect to the event view page using the returned event ID
if (newEvent) {
goto(`/private/events/event/view?id=${newEvent.id}`);
} else {
// Fallback to events list if for some reason the event ID wasn't returned
goto('/private/events');
}
} catch (error) {
console.error('Error creating event:', error);
toast.error('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>
<!-- Header -->
<StepNavigator {currentStep} {totalSteps} />
<!-- Step Content -->
<div class="mb-4 rounded border border-gray-300 bg-white p-6">
{#if currentStep === 0}
<GoogleAuthStep
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 />
{:else if currentStep === 2}
<GoogleSheetsStep
bind:sheetsData
{loadRecentSheets}
{selectSheet}
{toggleSheetList}
{resetSheetSelection}
/>
{:else if currentStep === 3}
<EmailSettingsStep bind:emailData />
{/if}
</div>
<!-- Navigation -->
<StepNavigation
{currentStep}
{totalSteps}
{canProceed}
{loading}
{prevStep}
{nextStep}
{createEvent}
/>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
let { emailData = $bindable() } = $props<{
emailData: {
subject: string;
body: string;
};
}>();
const templateVariables = [
{ name: '{name}', description: "Participant's first name" },
{ name: '{surname}', description: "Participant's last name" }
];
const subjectTemplatesDetected = $derived(
templateVariables.filter((v) => emailData.subject && emailData.subject.includes(v.name))
);
const bodyTemplatesDetected = $derived(
templateVariables.filter((v) => emailData.body && emailData.body.includes(v.name))
);
</script>
<div class="space-y-6">
<div>
<label for="emailSubject" class="mb-2 block text-sm font-medium text-gray-700">
Email Subject *
</label>
<input
id="emailSubject"
type="text"
bind:value={emailData.subject}
class="w-full rounded border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Event invitation subject"
/>
{#if subjectTemplatesDetected.length > 0}
<p class="mt-1 text-xs text-gray-500">
Detected templates: {subjectTemplatesDetected.map((v) => v.name).join(', ')}
</p>
{/if}
</div>
<div>
<label for="emailBody" class="mb-2 block text-sm font-medium text-gray-700">
Email Body *
</label>
<textarea
id="emailBody"
bind:value={emailData.body}
rows="8"
class="w-full rounded border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Email message content..."
></textarea>
{#if bodyTemplatesDetected.length > 0}
<p class="text-xs text-gray-500">
Detected templates: {bodyTemplatesDetected.map((v) => v.name).join(', ')}
</p>
{/if}
</div>
<div>
<p class="mt-2 mb-2 block text-sm font-medium text-gray-700">Tip:</p>
<p class="text-xs text-gray-500">
Use <code class="rounded bg-gray-100 px-1 py-0.5 text-xs">&#123;name&#125;</code> and
<code class="rounded bg-gray-100 px-1 py-0.5 text-xs">&#123;surname&#125;</code> to personalize
your message. Works for both subject and body. (e.g., "Hello &#123;name&#125;, welcome to our event!")
</p>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
let { eventData = $bindable() } = $props<{
eventData: {
name: string;
date: 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"
/>
</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"
/>
</div>
</div>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props
let { onSuccess, onError } = $props<{
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}
/>
</div>
</div>

View File

@@ -0,0 +1,374 @@
<script lang="ts">
import type { GoogleSheet } from '$lib/google/sheets/types.ts';
// Props
let { sheetsData = $bindable(), loadRecentSheets, selectSheet, toggleSheetList, resetSheetSelection } = $props<{
sheetsData: {
availableSheets: GoogleSheet[];
selectedSheet: GoogleSheet | null;
sheetData: string[][];
columnMapping: {
name: number;
surname: number;
email: number;
confirmation: number;
};
loading: boolean;
expandedSheetList: boolean;
};
loadRecentSheets: () => Promise<void>;
selectSheet: (sheet: GoogleSheet) => Promise<void>;
toggleSheetList: () => void;
resetSheetSelection: () => 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={resetSheetSelection}
class="text-blue-600 hover:text-blue-800 flex items-center"
aria-label="Reset selection and 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="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" 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}
</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}
</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 mt-6">
<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,370 @@
<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 Statistics from './components/Statistics.svelte';
import { toast } from '$lib/stores/toast.js';
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 updatingEmail = $state(false);
let emailProgress = $state({ sent: 0, total: 0 });
let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null);
// 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);
toast.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);
toast.error('Failed to load participants');
} finally {
participantsLoading = false;
}
}
async function syncParticipants() {
if (!event || !event.sheet_id) {
toast.error('Cannot sync participants: No Google Sheet is connected to this event');
return;
}
// Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
toast.error('Please connect your Google account first to sync participants');
return;
}
syncingParticipants = true;
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();
// Show success message with count of synced participants
const previousCount = participants.length;
const newCount = names.length;
const addedCount = Math.max(0, participants.length - previousCount);
toast.success(
`Successfully synced participants. ${newCount} entries processed, ${addedCount} new participants added.`,
5000
);
} catch (err) {
console.error('Error syncing participants:', err);
toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
syncingParticipants = false;
}
}
async function sendEmailsToUncontacted() {
if (!event) return;
// Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
toast.add({
message: 'Please connect your Google account first to send emails',
type: 'error'
});
return;
}
const uncontactedParticipants = participants.filter(p => !p.email_sent);
if (uncontactedParticipants.length === 0) {
toast.add({
message: 'No uncontacted participants found',
type: 'warning'
});
return;
}
sendingEmails = true;
emailProgress = { sent: 0, total: uncontactedParticipants.length };
emailResults = null;
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();
toast.add({
message: errorData.error || 'Failed to send emails',
type: 'error'
});
console.error('Email sending failed:', errorData);
}
} catch (err) {
console.error('Error sending emails:', err);
toast.add({
message: 'Failed to send emails to participants',
type: 'error'
});
} finally {
sendingEmails = false;
}
}
// For Email Template updating
async function handleEmailUpdate(eventId: string, subject: string, body: string) {
updatingEmail = true;
try {
// Call the email_modify RPC function
const { error } = await data.supabase.rpc('email_modify', {
p_event_id: eventId,
p_email_subject: subject,
p_email_body: body
});
if (error) throw error;
// Update the local event data on success
if (event) {
event.email_subject = subject;
event.email_body = body;
}
toast.add({
message: 'Email template updated successfully',
type: 'success'
});
} catch (err) {
console.error('Error updating email template:', err);
toast.add({
message: 'Failed to update email template',
type: 'error'
});
} finally {
updatingEmail = false;
}
}
function handleGoogleAuthSuccess() {
// Success handled by toast in the component
}
function handleGoogleAuthError(errorMsg: string) {
toast.add({
message: errorMsg,
type: 'error'
});
}
</script>
<div class="mt-2 mb-4">
<h1 class="text-center text-2xl font-bold">Event Overview</h1>
</div>
<EventInformation {event} {loading} />
<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}
{updatingEmail}
onUpdateEmail={handleEmailUpdate}
/>
<EmailSending
{loading}
{participants}
{sendingEmails}
{emailProgress}
{event}
onSendEmails={sendEmailsToUncontacted}
/>
{#if emailResults}
<EmailResults {emailResults} />
{/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,150 @@
<script lang="ts">
interface Event {
id: string;
email_subject: string;
email_body: string;
}
let {
event,
loading,
updatingEmail,
onUpdateEmail
} = $props<{
event: Event | null;
loading: boolean;
updatingEmail: boolean;
onUpdateEmail: (eventId: string, subject: string, body: string) => void;
}>();
// State for form
let isEditing = $state(false);
let emailSubject = $state('');
let emailBody = $state('');
// Update form values when event changes
$effect(() => {
if (event) {
emailSubject = event.email_subject;
emailBody = event.email_body;
}
});
// Toggle editing mode
function toggleEdit() {
isEditing = !isEditing;
// Reset form when exiting edit mode without saving
if (!isEditing && event) {
emailSubject = event.email_subject;
emailBody = event.email_body;
}
}
// Track the previous updatingEmail state to detect changes
let wasUpdating = $state(false);
// Save email template
function handleSave() {
if (!event) return;
onUpdateEmail(event.id, emailSubject, emailBody);
}
// Watch for updatingEmail changes to detect when operation completes
$effect(() => {
// Detect the transition from updating to not updating (operation completed)
if (wasUpdating && !updatingEmail) {
// If event data matches our form data, the update was successful
// Turn off editing mode after successful update
if (event && event.email_subject === emailSubject && event.email_body === emailBody) {
isEditing = false;
}
}
// Store current state for next comparison
wasUpdating = updatingEmail;
});
</script>
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Email Template</h2>
{#if !loading && event}
<div class="flex gap-3">
{#if isEditing}
<button
onclick={handleSave}
disabled={updatingEmail}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Save email template"
>
{updatingEmail ? 'Saving...' : 'Save'}
</button>
<button
onclick={toggleEdit}
class="rounded border border-gray-300 bg-white px-4 py-2 text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={updatingEmail}
aria-label="Cancel editing"
>
Cancel
</button>
{:else}
<button
onclick={toggleEdit}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={updatingEmail}
aria-label="Edit email template"
>
Edit Email
</button>
{/if}
</div>
{/if}
</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>
<label for="emailSubject" class="block mb-1 text-sm font-medium text-gray-700">Subject:</label>
<input
id="emailSubject"
type="text"
bind:value={emailSubject}
disabled={!isEditing || updatingEmail}
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-blue-500 disabled:cursor-default disabled:bg-gray-100"
/>
</div>
<div>
<label for="emailBody" class="block mb-1 text-sm font-medium text-gray-700">Body:</label>
<textarea
id="emailBody"
bind:value={emailBody}
rows="6"
disabled={!isEditing || updatingEmail}
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-blue-500 disabled:cursor-default disabled:bg-gray-100"
></textarea>
{#if isEditing}
<div class="mt-2 text-xs text-gray-500">
Template variables: <span class="font-mono bg-gray-100 px-1 rounded">&#123;name&#125;</span>,
<span class="font-mono bg-gray-100 px-1 rounded">&#123;surname&#125;</span>
</div>
{/if}
</div>
<!-- Save button moved to the header -->
</div>
{/if}
</div>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
message,
type = 'error',
duration = 50000,
onDismiss
} = $props<{
message: string;
type?: 'error' | 'success' | 'warning' | 'info';
duration?: number;
onDismiss?: () => void;
}>();
let visible = $state(true);
let timeoutId: ReturnType<typeof setTimeout>;
// Auto-dismiss after specified duration
onMount(() => {
if (duration > 0) {
timeoutId = setTimeout(() => {
dismiss();
}, duration);
}
// Cleanup timeout on component destroy
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function dismiss() {
visible = false;
if (onDismiss) {
onDismiss();
}
}
// Get styles based on toast type
const getToastStyles = (type: string) => {
switch (type) {
case 'success':
return 'border-green-200 bg-green-50 text-green-800';
case 'warning':
return 'border-yellow-200 bg-yellow-50 text-yellow-800';
case 'info':
return 'border-blue-200 bg-blue-50 text-blue-800';
case 'error':
default:
return 'border-red-200 bg-red-50 text-red-800';
}
};
// Get icon based on toast type
const getIcon = (type: string) => {
switch (type) {
case 'success':
return '✓';
case 'warning':
return '⚠';
case 'info':
return '';
case 'error':
default:
return '✕';
}
};
</script>
{#if visible && message}
<div
class="fixed top-4 left-4 z-50 max-w-sm rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out {getToastStyles(type)}"
role="alert"
aria-live="polite"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="flex-shrink-0">
<span class="text-lg font-semibold" aria-hidden="true">
{getIcon(type)}
</span>
</div>
<!-- Message -->
<div class="flex-1">
<p class="text-sm font-medium">
{message}
</p>
</div>
<!-- Close button -->
<button
onclick={dismiss}
class="flex-shrink-0 ml-2 text-current opacity-70 hover:opacity-100 transition-opacity"
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Progress bar for auto-dismiss -->
{#if duration > 0}
<div class="mt-2 h-1 w-full bg-black bg-opacity-10 rounded-full overflow-hidden">
<div
class="h-full bg-current opacity-30 transition-all ease-linear"
style="animation: progress {duration}ms linear forwards;"
></div>
</div>
{/if}
</div>
{/if}
<style>
@keyframes progress {
from {
width: 100%;
}
to {
width: 0%;
}
}
</style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
interface Event {
name: string;
date: string;
created_at: string;
sheet_id: string;
}
let { event, loading } = $props<{
event: Event | null;
loading: boolean;
}>();
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}
<div class="py-8 text-center">
<p class="text-gray-600">No event information available</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,174 @@
<script lang="ts">
import { toast } from '$lib/stores/toast.js';
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;
}>();
// Handle sync participants with toast notifications
function handleSyncParticipants() {
// Show initial notification about sync starting
toast.info('Starting participant synchronization...', 5000);
// Call the parent component's sync function
onSyncParticipants();
}
</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={handleSyncParticipants}
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,105 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
message,
type = 'error',
duration = 5000,
onDismiss
} = $props<{
message: string;
type?: 'error' | 'success' | 'warning' | 'info';
duration?: number;
onDismiss?: () => void;
}>();
let visible = $state(true);
let timeoutId: ReturnType<typeof setTimeout>;
// Auto-dismiss after specified duration
onMount(() => {
if (duration > 0) {
timeoutId = setTimeout(() => {
dismiss();
}, duration);
}
// Cleanup timeout on component destroy
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function dismiss() {
visible = false;
if (onDismiss) {
onDismiss();
}
}
// Get styles based on toast type
const getToastStyles = (type: string) => {
const baseStyles = "fixed top-4 left-4 z-50 p-4 rounded-lg shadow-lg border max-w-sm";
switch (type) {
case 'success':
return `${baseStyles} bg-green-50 border-green-200 text-green-800`;
case 'warning':
return `${baseStyles} bg-yellow-50 border-yellow-200 text-yellow-800`;
case 'info':
return `${baseStyles} bg-blue-50 border-blue-200 text-blue-800`;
case 'error':
default:
return `${baseStyles} bg-red-50 border-red-200 text-red-800`;
}
};
const getIconSvg = (type: string) => {
switch (type) {
case 'success':
return `<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />`;
case 'warning':
return `<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />`;
case 'info':
return `<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />`;
case 'error':
default:
return `<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />`;
}
};
</script>
{#if visible}
<div class={getToastStyles(type)} role="alert">
<div class="flex items-start gap-3">
<!-- Icon -->
<svg
class="h-5 w-5 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
{@html getIconSvg(type)}
</svg>
<!-- Message -->
<div class="flex-1">
<p class="text-sm font-medium">{message}</p>
</div>
<!-- Close button -->
<button
onclick={dismiss}
class="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,71 @@
import type { SupabaseClient } from '@supabase/supabase-js';
export interface Event {
id: string;
name: string;
date: string;
archived: boolean;
}
/**
* Unified function to get events or search events based on a search term
* @param supabase The Supabase client
* @param searchTerm Optional search term - if provided, will filter by name
* @returns Combined array of regular and archived events
*/
export async function getEvents(supabase: SupabaseClient, searchTerm: string = '') {
try {
const searchPattern = searchTerm.trim() ? `%${searchTerm}%` : null;
// Build regular events query
let regularQuery = supabase
.from('events')
.select('id, name, date')
.order('date', { ascending: false });
// Apply search filter if needed
if (searchPattern) {
regularQuery = regularQuery.ilike('name', searchPattern);
}
// Fetch regular events
const { data: regularEvents, error: regularError } = await regularQuery;
if (regularError) throw regularError;
// Build archived events query
let archivedQuery = supabase
.from('events_archived')
.select('id, name, date')
.order('date', { ascending: false })
.limit(searchPattern ? 50 : 20); // Fetch more when searching
// Apply search filter if needed
if (searchPattern) {
archivedQuery = archivedQuery.ilike('name', searchPattern);
}
// Fetch archived events
const { data: archivedEvents, error: archivedError } = await archivedQuery;
if (archivedError) throw archivedError;
// Merge both arrays, marking archived events
const regularMapped = (regularEvents || []).map((event) => ({ ...event, archived: false }));
const archivedMapped = (archivedEvents || []).map((event) => ({
...event,
archived: true
}));
// Sort all events by date (newest first)
const combined = [...regularMapped, ...archivedMapped];
combined.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return combined;
} catch (error) {
console.error('Error fetching events:', error);
throw new Error(searchTerm.trim()
? `Failed to search events for "${searchTerm}"`
: 'Failed to load events');
}
}

View File

@@ -1,22 +0,0 @@
// 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,51 +1,73 @@
<script lang="ts">
import type { User } from '@supabase/supabase-js';
export let data: {
user: User | null,
user_profile: any | null
};
let { data } = $props();
</script>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">User Profile</h1>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">User Dashboard</h1>
<div class="mb-4 rounded border border-gray-300 bg-white p-6">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3 mb-4">
<div class="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-600">
{data.user?.user_metadata.display_name?.[0] ?? "U"}
</div>
<div>
<span class="text-lg font-semibold text-gray-800">{data.user?.user_metadata.display_name}</span>
<div class="text-sm text-gray-500">{data.user?.email}</div>
</div>
</div>
<div class="flex flex-col gap-1">
<div>
<span class="font-medium text-gray-700">Section:</span>
<span class="text-gray-900">{data.user_profile?.section.name ?? "N/A"}</span>
</div>
<div>
<span class="font-medium text-gray-700">Position:</span>
<span class="text-gray-900">{data.user_profile?.section_position ?? "N/A"}</span>
</div>
</div>
<h2 class="text-lg mb-2 mt-4">User guide</h2>
<p class="text-gray-700 text-sm leading-relaxed">
To scan a QR code, head over to Scanner in the top right corner. Click on Start scanning and allow camera permissions.
If you close and open your browser and your camera is stuck, simply refresh the page or click Stop scanning and then Start scanning again.
When you scan a QR code, a request is sent to the server to get the user's personal information and to mark their tickets as scanned.
</p>
<h2 class="text-lg mb-2 mt-4">Administrator guide</h2>
<p class="text-gray-700 text-sm leading-relaxed">
You can view events
</p>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column: User Profile -->
<div class="lg:col-span-1">
<div class="flex h-full flex-col rounded-lg border border-gray-300 bg-white p-6">
<div class="flex flex-grow flex-col items-center text-center">
<div
class="mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-4xl font-bold text-gray-600"
>
{data.profile?.display_name?.[0]?.toUpperCase() ?? 'U'}
</div>
<h2 class="text-xl font-semibold text-gray-900">{data.profile?.display_name}</h2>
<p class="text-sm text-gray-500">{data.user?.email}</p>
</div>
<div class="mt-6 text-center">
<a
href="/auth/signout"
class="text-sm text-red-500 transition hover:text-red-700 hover:underline">Sign Out</a
>
</div>
</div>
</div>
<!-- Right Column: Information -->
<div class="space-y-6 lg:col-span-2">
<!-- Role Information -->
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Your Role</h2>
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Section</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">
{data.profile?.section?.name ?? 'N/A'}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Position</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">
{data.profile?.section_position ?? 'N/A'}
</dd>
</div>
</dl>
</div>
<!-- User Guide -->
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-2 text-lg font-semibold text-gray-900">User Guide</h2>
<p class="text-sm leading-relaxed text-gray-700">
To scan a QR code, head over to <strong>Scanner</strong> in the top right corner. Click on "Start
Scanning" and allow camera permissions. If your camera gets stuck, simply refresh the page or
click "Stop Scanning" and then "Start Scanning" again. When you scan a QR code, the participant's
ticket is automatically marked as scanned.
</p>
</div>
<!-- Events Manager Guide -->
{#if data.profile?.section_position === 'events_manager'}
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-2 text-lg font-semibold text-gray-900">Events Manager Guide</h2>
<p class="text-sm leading-relaxed text-gray-700">
As an Events Manager, you have access to the <strong>Events</strong> section. Here you can
create new events, manage participants by syncing with Google Sheets, send email invitations
with QR codes, and view event statistics.
</p>
</div>
{/if}
</div>
</div>
<a
href="/auth/signout"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-red-500 hover:bg-red-600 text-white font-semibold py-3 px-8 rounded-full shadow-none border border-gray-300 transition"
>
Sign out
</a>

View File

@@ -1,36 +1,149 @@
<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 () => {
// Load the persisted event ID from local storage
const storedEventId = localStorage.getItem('selectedScannerEventId');
if (storedEventId) {
selectedEventId = storedEventId;
}
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 || [];
// Check if the previously selected event is still in the list
const selectedEventExists = events.some((event) => event.id === selectedEventId);
// If no event is selected, or the selected one is no longer valid, default to the first event
if ((!selectedEventId || !selectedEventExists) && events.length > 0) {
selectedEventId = events[0].id;
} else if (events.length === 0) {
selectedEventId = ''; // No events available
}
} catch (err) {
console.error('Error loading events:', err);
eventsError = 'Failed to load events';
} finally {
isLoadingEvents = false;
}
}
// Persist the selected event ID to local storage whenever it changes
$effect(() => {
if (scanned_id === "") return;
if (selectedEventId) {
localStorage.setItem('selectedScannerEventId', selectedEventId);
}
});
// Process a scanned QR code
$effect(() => {
if (scanned_id === '') return;
scan_state = ScanState.scanning;
console.log('Scanned ID:', scanned_id);
data.supabase
.from('participants')
.select(`*, event ( id, name ), scanned_by ( id, display_name )`)
.eq('id', scanned_id)
.then( response => {
if (response.data && response.data.length > 0) {
ticket_data = response.data[0];
scan_state = ScanState.scan_successful;
data.supabase.rpc('scan_ticket', { _ticket_id: scanned_id}).then();
} else {
ticket_data = defaultTicketData;
scan_state = ScanState.scan_failed;
}
})
.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

@@ -45,9 +45,9 @@
});
</script>
<div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded-sm"></div>
<div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded"></div>
<style>
<style lang="postcss">
/* Hide unwanted icons */
#qr-scanner :global(img[alt='Info icon']),
#qr-scanner :global(img[alt='Camera based scan']) {
@@ -58,21 +58,51 @@
color: black !important;
}
/* Change camera permission button text */
#qr-scanner :global(#html5-qrcode-button-camera-permission) {
visibility: hidden;
}
#qr-scanner :global(#html5-qrcode-button-camera-permission::after) {
position: absolute;
inset: auto 0 0;
display: block;
content: 'Allow camera access';
visibility: visible;
padding: 10px 0;
}
#qr-scanner :global(#qr-scanner__scan_region) {
min-height: auto !important;
aspect-ratio: 1 !important;
}
#qr-scanner :global(button.html5-qrcode-element) {
border-radius: 0.375rem;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
background-color: #2563eb;
color: #fff;
font-weight: 500;
transition-property: background-color, border-color, color, fill, stroke;
transition-duration: 150ms;
outline: none;
}
#qr-scanner :global(button.html5-qrcode-element:hover) {
background-color: #1d4ed8;
}
#qr-scanner :global(button.html5-qrcode-element:focus) {
box-shadow: 0 0 0 2px #60a5fa, 0 0 0 4px #fff;
}
#qr-scanner :global(input) {
border-radius: 0.375rem; /* rounded-md */
border-width: 1px;
border-color: #d1d5db; /* border-gray-300 */
padding-left: 1rem; /* px-4 */
padding-right: 1rem;
padding-top: 0.5rem; /* py-2 */
padding-bottom: 0.5rem;
background-color: #f9fafb; /* bg-gray-50 */
color: #111827; /* text-gray-900 */
font-size: 1rem;
line-height: 1.5rem;
outline: none;
transition-property: border-color, box-shadow;
transition-duration: 150ms;
}
#qr-scanner :global(input:focus) {
border-color: #2563eb; /* border-blue-600 */
box-shadow: 0 0 0 2px #60a5fa;
}
</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,37 +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-red-500 bg-red-100 p-4 text-red-700">
<p><strong>Scan failed!</strong></p>
<p>This is either not a valid ticket or this ticket has been purchased from a different section.</p>
</div>
{:else if scan_state === ScanState.scan_successful}
{#if ticket_data.scanned}
<div class="rounded border-l-4 border-red-500 bg-red-100 p-4 text-red-700">
<p>Ticket already scanned!</p>
<p>
By {ticket_data.scanned_by?.display_name} on
{formatScannedAt(ticket_data.scanned_at)}
</p>
<hr class="my-2 border-t border-red-300" />
<ol>
<li><strong>{ticket_data.event.name}</strong></li>
<li>{ticket_data.name} {ticket_data.surname}</li>
</ol>
<div class="border border-gray-300 rounded-lg overflow-hidden h-[200px] mb-4">
<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}
<div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700">
<ol>
<li><strong>{ticket_data.event.name}</strong></li>
<li>{ticket_data.name} {ticket_data.surname}</li>
</ol>
{: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-bold">{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-bold">{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-bold">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div>
</div>
{/if}
{/if}
</div>
</div>

View File

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

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/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

364
styling.md Normal file
View File

@@ -0,0 +1,364 @@
# ScanWave Styling Guide
This document outlines the design system and styling conventions used in the ScanWave application. Use this as a reference when creating new applications with similar visual design.
## Table of Contents
- [Color Palette](#color-palette)
- [Typography](#typography)
- [Layout Patterns](#layout-patterns)
- [Component Patterns](#component-patterns)
- [Form Elements](#form-elements)
- [Buttons](#buttons)
- [Cards and Containers](#cards-and-containers)
- [Navigation](#navigation)
- [Tables](#tables)
- [Loading States](#loading-states)
- [Toast Notifications](#toast-notifications)
- [Responsive Design](#responsive-design)
## Color Palette
### Primary Colors
- **Blue**: Primary action color
- `bg-blue-600` / `text-blue-600` - Primary buttons, links
- `bg-blue-700` / `text-blue-700` - Hover states
- `bg-blue-50` / `text-blue-800` - Info notifications
- `border-blue-600` / `focus:ring-blue-600` - Focus states
### Gray Scale
- **Text Colors**:
- `text-gray-900` - Primary text (headings, important content)
- `text-gray-700` - Secondary text (labels, descriptions)
- `text-gray-500` - Tertiary text (metadata, placeholders)
- **Background Colors**:
- `bg-white` - Main content backgrounds
- `bg-gray-50` - Page backgrounds, subtle sections
- `bg-gray-100` - Disabled form fields
- `bg-gray-200` - Loading placeholders
- **Border Colors**:
- `border-gray-300` - Standard borders (cards, inputs)
- `border-gray-200` - Subtle borders (table rows)
### Status Colors
- **Success**: `bg-green-50 text-green-800 border-green-300`
- **Warning**: `bg-yellow-50 text-yellow-800 border-yellow-300`
- **Error**: `bg-red-50 text-red-800 border-red-300`
- **Info**: `bg-blue-50 text-blue-800 border-blue-300`
### Accent Colors
- **Red**: `text-red-600` / `hover:text-red-700` - Danger actions (sign out)
- **Green**: `text-green-600` - Success indicators
## Typography
### Headings
```html
<!-- Page titles -->
<h1 class="mb-6 text-2xl font-bold text-center text-gray-800">Page Title</h1>
<!-- Section headings -->
<h2 class="mb-4 text-xl font-semibold text-gray-900">Section Title</h2>
<h2 class="mb-2 text-lg font-semibold text-gray-900">Subsection Title</h2>
```
### Body Text
```html
<!-- Primary text -->
<p class="text-sm text-gray-900">Important content</p>
<!-- Secondary text -->
<p class="text-sm text-gray-700">Regular content</p>
<p class="text-sm leading-relaxed text-gray-700">Longer content blocks</p>
<!-- Metadata/labels -->
<span class="text-sm font-medium text-gray-500">Label</span>
<span class="text-sm font-medium text-gray-700">Form Label</span>
<!-- Small text -->
<p class="text-xs text-gray-500">Helper text</p>
```
### Text Utilities
- **Font Weight**: `font-bold`, `font-semibold`, `font-medium`
- **Text Alignment**: `text-center`, `text-left`
- **Line Height**: `leading-relaxed` for longer text blocks
## Layout Patterns
### Container Pattern
```html
<div class="container mx-auto max-w-2xl bg-white p-4">
<!-- Content -->
</div>
```
### Grid Layouts
```html
<!-- Dashboard grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-1"><!-- Sidebar --></div>
<div class="lg:col-span-2"><!-- Main content --></div>
</div>
<!-- Two-column responsive -->
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Items -->
</dl>
```
### Spacing
- **Standard spacing**: `space-y-6`, `gap-6` - Between major sections
- **Component spacing**: `mb-4`, `mt-6`, `p-6` - Around components
- **Small spacing**: `gap-3`, `mb-2`, `mt-2` - Between related elements
- **Container padding**: `p-4`, `p-6` - Internal container spacing
## Component Patterns
### Card Structure
```html
<div class="rounded-lg border border-gray-300 bg-white p-6">
<div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Title</h2>
<!-- Actions -->
</div>
<!-- Content -->
</div>
```
### Avatar/Profile Picture
```html
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-4xl font-bold text-gray-600">
{initials}
</div>
```
## Form Elements
### Input Fields
```html
<!-- Standard input -->
<input
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-600 focus:ring-blue-600 focus:outline-none"
type="text"
/>
<!-- Disabled input -->
<input
class="w-full rounded-md border border-gray-300 px-3 py-2 disabled:cursor-default disabled:bg-gray-100"
disabled
/>
```
### Textarea
```html
<textarea
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-600 focus:ring-blue-600 focus:outline-none"
rows="6"
></textarea>
```
### Select Dropdown
```html
<select class="w-full rounded-md border border-gray-300 p-2 focus:ring-2 focus:ring-blue-600 focus:outline-none">
<option>Option</option>
</select>
```
### Form Labels
```html
<label class="block mb-1 text-sm font-medium text-gray-700">Label Text</label>
```
## Buttons
### Primary Buttons
```html
<button class="rounded-md bg-blue-600 px-4 py-2 text-white font-medium transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50">
Primary Action
</button>
```
### Secondary/Outline Buttons
```html
<button class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-700 font-medium transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50">
Secondary Action
</button>
```
### Danger/Red Buttons
```html
<button class="rounded-md bg-red-600 px-4 py-2 text-white font-medium transition hover:bg-red-700">
Danger Action
</button>
```
### Button States
- **Loading**: Replace text with "Loading..." or "Saving..."
- **Disabled**: `disabled:cursor-not-allowed disabled:opacity-50`
## Cards and Containers
### Standard Card
```html
<div class="mb-6 rounded-md border border-gray-300 bg-white p-6">
<!-- Content -->
</div>
```
### Card with Header Actions
```html
<div class="rounded-md border border-gray-300 bg-white p-6">
<div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Title</h2>
<div class="flex gap-3">
<!-- Action buttons -->
</div>
</div>
<!-- Content -->
</div>
```
## Navigation
### Top Navigation
```html
<nav class="border-b border-gray-300 bg-gray-50 p-4 text-gray-900">
<div class="container mx-auto max-w-2xl">
<div class="flex items-center justify-between">
<a href="/" class="text-lg font-bold">App Name</a>
<ul class="flex space-x-4">
<li><a href="/page" class="hover:text-blue-600 transition">Page</a></li>
</ul>
</div>
</div>
</nav>
```
## Tables
### Standard Table
```html
<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">Header</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-900">Data</td>
</tr>
</tbody>
</table>
</div>
```
### Definition List (Key-Value Pairs)
```html
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Key</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">Value</dd>
</div>
</dl>
```
## Loading States
### Skeleton Loading
```html
<div class="space-y-4">
<div class="h-4 animate-pulse rounded-md bg-gray-200"></div>
<div class="h-10 w-full animate-pulse rounded-md bg-gray-200"></div>
</div>
```
### Loading Spinner
```html
<div class="flex h-10 items-center justify-center">
<div class="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
</div>
```
## Toast Notifications
### Toast Container Structure
```html
<div class="rounded-md border p-4 shadow-lg w-full {colorClasses}">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<!-- Icon -->
</div>
<div class="flex-1">
<p class="text-sm font-medium">{message}</p>
</div>
<button class="flex-shrink-0">
<!-- Close button -->
</button>
</div>
</div>
```
### Toast Color Variants
- **Success**: `border-green-300 bg-green-50 text-green-800`
- **Warning**: `border-yellow-300 bg-yellow-50 text-yellow-800`
- **Info**: `border-blue-300 bg-blue-50 text-blue-800`
- **Error**: `border-red-300 bg-red-50 text-red-800`
## Responsive Design
### Breakpoints
- **Mobile First**: Default styles for mobile
- **sm**: `sm:` prefix for small screens and up
- **lg**: `lg:` prefix for large screens and up
### Common Responsive Patterns
```html
<!-- Responsive grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Responsive padding -->
<div class="p-4 sm:p-6">
<!-- Responsive columns -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
```
## Common Utility Classes
### Flexbox
- `flex items-center justify-between` - Header with title and actions
- `flex items-start gap-3` - Toast notification layout
- `flex flex-col` - Vertical stacking
- `flex-grow` - Fill available space
### Positioning
- `relative` / `absolute` - Positioning contexts
- `fixed bottom-6 left-1/2 -translate-x-1/2` - Centered fixed positioning
### Visibility
- `hidden` / `block` - Show/hide elements
- `overflow-hidden` - Clip content
- `overflow-x-auto` - Horizontal scroll for tables
### Borders and Shadows
- `rounded-md` - Standard border radius for all components
- `rounded-full` - Circular elements (avatars)
- `shadow-lg` - Toast notifications and elevated elements
- `shadow-none` - Remove default shadows when needed
## Design Tokens Summary
### Standardized Values
- **Border Radius**: `rounded-md` (6px) for all rectangular components
- **Border Colors**: `border-gray-300` (standard), `border-gray-200` (subtle)
- **Focus States**: `focus:border-blue-600 focus:ring-blue-600`
- **Spacing**: `gap-4` (1rem), `gap-6` (1.5rem), `p-4` (1rem), `p-6` (1.5rem)
- **Font Weights**: `font-medium` for buttons and emphasis, `font-semibold` for headings, `font-bold` for titles
- **Status Border**: All status colors use `-300` shade for borders (e.g., `border-green-300`)
This styling guide captures the core design patterns used throughout the ScanWave application. Follow these conventions to maintain visual consistency across your new applications.

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$
;