diff --git a/.env.example b/.env.example
index e3df937..32ce1d1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,7 @@
PUBLIC_SUPABASE_URL=https://abc.supabase.co
-PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index c53c216..084af10 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -85,4 +85,89 @@ 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
\ No newline at end of file
+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)
+);
+
diff --git a/package-lock.json b/package-lock.json
index 08b4194..914a67e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,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",
@@ -1515,6 +1516,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 +1623,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 +2177,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 +2681,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 +2936,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 +2990,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 +3204,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 +3285,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 +3366,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 +3645,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",
diff --git a/package.json b/package.json
index e904f68..b069470 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/lib/components/GoogleAuthButton.svelte b/src/lib/components/GoogleAuthButton.svelte
new file mode 100644
index 0000000..928fcbd
--- /dev/null
+++ b/src/lib/components/GoogleAuthButton.svelte
@@ -0,0 +1,117 @@
+
+
+{#if authState.isConnected}
+
+
+
+ {#if authState.userEmail}
+
+
+
+
+
{authState.userEmail}
+
+ {/if}
+
+
+
+
+
+ Disconnect
+
+
+{:else}
+
+
+ {#if authState.connecting}
+
+ Connecting...
+ {:else}
+
+
+
+
+
+
+ Connect to Google
+ {/if}
+
+
+ {#if authState.error}
+
+ {authState.error}
+
+ {/if}
+
+{/if}
diff --git a/src/lib/google/auth/client.ts b/src/lib/google/auth/client.ts
new file mode 100644
index 0000000..1a5c535
--- /dev/null
+++ b/src/lib/google/auth/client.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/src/lib/google/auth/manager.ts b/src/lib/google/auth/manager.ts
new file mode 100644
index 0000000..f87901e
--- /dev/null
+++ b/src/lib/google/auth/manager.ts
@@ -0,0 +1,120 @@
+import { authenticateWithGoogle } from '$lib/google/auth/client.js';
+
+export interface GoogleAuthState {
+ isConnected: boolean;
+ checking: boolean;
+ connecting: boolean;
+ showCancelOption: boolean;
+ token: string | null;
+ error: string | null;
+ userEmail: string | null;
+}
+
+export function createGoogleAuthState(): GoogleAuthState {
+ return {
+ isConnected: false,
+ checking: false,
+ connecting: false,
+ showCancelOption: false,
+ token: null,
+ error: null,
+ userEmail: null
+ };
+}
+
+export class GoogleAuthManager {
+ private readonly state: GoogleAuthState;
+ private cancelTimeout: ReturnType | null = null;
+
+ constructor(state: GoogleAuthState) {
+ this.state = state;
+ }
+
+ checkConnection(): void {
+ this.state.checking = true;
+ this.state.error = null;
+
+ try {
+ const token = localStorage.getItem('google_refresh_token');
+ const email = localStorage.getItem('google_user_email');
+
+ this.state.isConnected = !!token;
+ this.state.token = token;
+ this.state.userEmail = email;
+ } catch (error) {
+ console.error('Error checking connection:', error);
+ this.state.error = 'Failed to check connection status';
+ } finally {
+ this.state.checking = false;
+ }
+ }
+
+ async connectToGoogle(): Promise {
+ 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 {
+ 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';
+ }
+ }
+}
diff --git a/src/lib/google/auth/server.ts b/src/lib/google/auth/server.ts
new file mode 100644
index 0000000..6e937ae
--- /dev/null
+++ b/src/lib/google/auth/server.ts
@@ -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;
+}
diff --git a/src/lib/google/client.ts b/src/lib/google/client.ts
new file mode 100644
index 0000000..74da0a6
--- /dev/null
+++ b/src/lib/google/client.ts
@@ -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';
+
diff --git a/src/lib/google.ts b/src/lib/google/gmail/server.ts
similarity index 77%
rename from src/lib/google.ts
rename to src/lib/google/gmail/server.ts
index 792deb2..9130843 100644
--- a/src/lib/google.ts
+++ b/src/lib/google/gmail/server.ts
@@ -1,45 +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',
- 'https://www.googleapis.com/auth/userinfo.email'
-];
-
-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 =
- `
+/**
+ * Create an HTML email template with ScanWave branding
+ * @param text - Email body text
+ * @returns HTML email template
+ */
+export function createEmailTemplate(text: string): string {
+ return `
+
+
+
+
✓ Authentication successful!
+
Closing window...
+
+
+
+`;
+
+ 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');
+ }
+};
diff --git a/src/routes/private/api/gmail/+server.ts b/src/routes/private/api/gmail/+server.ts
index ca0e7d4..eea6c9b 100644
--- a/src/routes/private/api/gmail/+server.ts
+++ b/src/routes/private/api/gmail/+server.ts
@@ -1,79 +1,154 @@
+import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
-import { json, redirect } from '@sveltejs/kit';
-import {
- createAuthUrl,
- exchangeCodeForTokens,
- sendGmail,
- getOAuthClient
-} from '$lib/google';
+import { sendGmail } from '$lib/google/gmail/server.js';
+import QRCode from 'qrcode';
-/* ───────────── 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());
- }
+interface Participant {
+ id: string;
+ name: string;
+ surname: string;
+ email: string;
+}
- /* 2. Google callback /private/api/gmail?code=XXXX */
- const code = url.searchParams.get('code');
- if (code) {
+interface EmailResult {
+ participant: Participant;
+ success: boolean;
+ error?: string;
+}
+
+async function generateQRCode(participantId: string): Promise {
+ 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 {
+ try {
+ const qrCodeBase64Data = await generateQRCode(participant.id);
+
+ // Send email with QR code
+ await sendGmail(refreshToken, {
+ to: participant.email,
+ subject: subject,
+ text: text,
+ qr_code: qrCodeBase64Data
+ });
+
+
+ // Call the participant_emailed RPC function
try {
- const refreshToken = await exchangeCodeForTokens(code);
-
- const html = `
- `;
- return new Response(html, { headers: { 'Content-Type': 'text/html' } });
- } catch (err) {
- return new Response((err as Error).message, { status: 500 });
+ 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 };
}
- 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 });
+ 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 });
+ }
};
diff --git a/src/routes/private/api/google/README.md b/src/routes/private/api/google/README.md
new file mode 100644
index 0000000..6d1f46b
--- /dev/null
+++ b/src/routes/private/api/google/README.md
@@ -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.
diff --git a/src/routes/private/api/google/auth/refresh/+server.ts b/src/routes/private/api/google/auth/refresh/+server.ts
new file mode 100644
index 0000000..ca057c8
--- /dev/null
+++ b/src/routes/private/api/google/auth/refresh/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/private/api/google/auth/revoke/+server.ts b/src/routes/private/api/google/auth/revoke/+server.ts
new file mode 100644
index 0000000..b01adee
--- /dev/null
+++ b/src/routes/private/api/google/auth/revoke/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/private/api/google/auth/userinfo/+server.ts b/src/routes/private/api/google/auth/userinfo/+server.ts
new file mode 100644
index 0000000..2e44e75
--- /dev/null
+++ b/src/routes/private/api/google/auth/userinfo/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts
new file mode 100644
index 0000000..e0ca677
--- /dev/null
+++ b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/private/api/google/sheets/recent/+server.ts b/src/routes/private/api/google/sheets/recent/+server.ts
new file mode 100644
index 0000000..304c575
--- /dev/null
+++ b/src/routes/private/api/google/sheets/recent/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/private/api/google/sheets/search/+server.ts b/src/routes/private/api/google/sheets/search/+server.ts
new file mode 100644
index 0000000..2c79e6c
--- /dev/null
+++ b/src/routes/private/api/google/sheets/search/+server.ts
@@ -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');
+ }
+};
diff --git a/src/routes/private/events/+page.server.ts b/src/routes/private/events/+page.server.ts
deleted file mode 100644
index be0d7a9..0000000
--- a/src/routes/private/events/+page.server.ts
+++ /dev/null
@@ -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 };
-}
\ No newline at end of file
diff --git a/src/routes/private/events/+page.svelte b/src/routes/private/events/+page.svelte
index 47dc1f6..d8d3cfb 100644
--- a/src/routes/private/events/+page.svelte
+++ b/src/routes/private/events/+page.svelte
@@ -1,54 +1,237 @@
All Events
+
- {#if loading}
- {#each Array(4) as _}
-
- {/each}
- {:else}
- {#each events as event}
-
- {/each}
- {#each archived_events as event}
-
- {/each}
- {/if}
+ {#if loading}
+
+ {#each Array(4) as _}
+
+ {/each}
+ {:else if error}
+
+
{error}
+
+ Try Again
+
+
+ {:else if displayEvents.length === 0}
+
+
No events found. Create your first event!
+
+ {:else}
+ {#each displayEvents as event}
+
+ {/each}
+ {/if}
-
- New Event
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+ {#if isSearching}
+
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+ {#if searchTerm}
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+ New Event
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/routes/private/events/SingleEvent.svelte b/src/routes/private/events/SingleEvent.svelte
index ef08e73..5887730 100644
--- a/src/routes/private/events/SingleEvent.svelte
+++ b/src/routes/private/events/SingleEvent.svelte
@@ -3,7 +3,7 @@
diff --git a/src/routes/private/events/archived/+page.svelte b/src/routes/private/events/archived/+page.svelte
deleted file mode 100644
index 2df8b53..0000000
--- a/src/routes/private/events/archived/+page.svelte
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-Archived Event Overview
-
-
-
- {#if loading}
-
-
- {:else}
-
{event_data?.name}
-
{event_data?.date}
- {/if}
-
-
-
-
-
-
-
-
- {#if loading}
-
- {:else}
-
Total participants ({event_data?.total_participants})
- {/if}
-
-
-
-
-
-
- {#if loading}
-
- {:else}
-
Scanned participants ({event_data?.scanned_participants})
- {/if}
-
-
diff --git a/src/routes/private/events/creator/+page.svelte b/src/routes/private/events/creator/+page.svelte
deleted file mode 100644
index 4a2818f..0000000
--- a/src/routes/private/events/creator/+page.svelte
+++ /dev/null
@@ -1,131 +0,0 @@
-
-
-{#if isAddingParticipants}
-
-
- Adding Participants to "{event.name}"
-
-
-{/if}
-
-{#if step == 0}
-
-{:else if step == 1}
-
-{:else if step == 2}
-
-{:else if step == 3}
-
-{:else if step == 4}
-
-{/if}
-
-
-
-
-
-
- Previous
-
-
- Step {step + 1} of {steps.length}
-
-
- Next
-
-
-
diff --git a/src/routes/private/events/creator/emailtest/+page.svelte b/src/routes/private/events/creator/emailtest/+page.svelte
deleted file mode 100644
index 3d67361..0000000
--- a/src/routes/private/events/creator/emailtest/+page.svelte
+++ /dev/null
@@ -1,110 +0,0 @@
-
-
-
-
Test Email Sender
- {#if !authorized}
-
-
Google not connected.
-
- Connect Google
-
-
-
- {/if}
- {#if error}
-
{error}
- {/if}
- {#if success}
-
{success}
- {/if}
-
diff --git a/src/routes/private/events/creator/finish/+page.svelte b/src/routes/private/events/creator/finish/+page.svelte
deleted file mode 100644
index 93dc831..0000000
--- a/src/routes/private/events/creator/finish/+page.svelte
+++ /dev/null
@@ -1,252 +0,0 @@
-
-
-
-
-
- {all_data.isAddingParticipants ? 'Using existing event' : 'Creating event'}
-
- {#if event_status === StepStatus.Waiting}
- Waiting...
- {:else if event_status === StepStatus.Loading}
- Creating event...
- {:else if event_status === StepStatus.Success}
-
- {all_data.isAddingParticipants ? 'Using existing event.' : 'Event created successfully.'}
-
- {:else if event_status === StepStatus.Failure}
- Failed to create event.
- {/if}
-
-
-
-
-
Creating QR codes for participants
- {#if participants_status === StepStatus.Waiting}
- Waiting...
- {:else if participants_status === StepStatus.Loading}
- Creating entries...
- {:else if participants_status === StepStatus.Success}
- QR codes created successfully.
- {:else if participants_status === StepStatus.Failure}
- Failed to create QR codes.
- {/if}
-
-
-
-
-
Sending emails
-
After pressing send, you must not exit this window until the mail are all sent!
- {#if email_status === StepStatus.Waiting}
-
- Waiting...
-
- Send all emails
-
-
- {:else}
-
- {#each createdParticipants as p, i}
-
- {#if mailStatuses[i]?.status === 'success'}
-
-
-
- {:else if mailStatuses[i]?.status === 'failure'}
-
-
-
-
-
- {:else}
-
-
-
- {/if}
- {p.name} {p.surname}
- {p.email}
-
- {/each}
-
- {#if email_status === StepStatus.Loading}
-
Sending emails...
- {:else if email_status === StepStatus.Success}
-
Emails sent successfully.
- {:else if email_status === StepStatus.Failure}
-
Failed to send emails.
- {/if}
- {/if}
-
diff --git a/src/routes/private/events/creator/steps/StepConnectGoogle.svelte b/src/routes/private/events/creator/steps/StepConnectGoogle.svelte
deleted file mode 100644
index 4d381c9..0000000
--- a/src/routes/private/events/creator/steps/StepConnectGoogle.svelte
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
- {#if loading}
-
-
-
-
-
-
Checking Google connection...
-
- {:else}
- {#if !authorized}
-
- You haven’t connected your Google account yet.
-
- Connect Google
-
-
- {:else}
-
-
-
-
-
Your connection to Google is good, proceed to next step
-
- {/if}
- {/if}
-
diff --git a/src/routes/private/events/creator/steps/StepCraftEmail.svelte b/src/routes/private/events/creator/steps/StepCraftEmail.svelte
deleted file mode 100644
index 085baaf..0000000
--- a/src/routes/private/events/creator/steps/StepCraftEmail.svelte
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-{#if showForm}
-
- Craft Email
-
- Subject
-
-
-
- Body
-
-
-
- Save
-
-
-{:else}
-
- Edit email
-
-
-
Email Preview
-
- {email.subject}
-
-
- {email.body}
-
-
-{/if}
\ No newline at end of file
diff --git a/src/routes/private/events/creator/steps/StepCreateEvent.svelte b/src/routes/private/events/creator/steps/StepCreateEvent.svelte
deleted file mode 100644
index ad0856e..0000000
--- a/src/routes/private/events/creator/steps/StepCreateEvent.svelte
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
-{#if showForm}
-
- Create Event
-
- Name
-
-
-
- Date
-
-
-
- Save
-
-
-{/if}
-
-{#if !showForm}
- {#if !readonly}
-
- Edit the event
-
- {/if}
-
-
Event Preview
- {#if Object.keys(event).length > 0}
-
- {event.name}
- {event.date}
-
- {:else}
-
No event created yet...
- {/if}
-
-{/if}
diff --git a/src/routes/private/events/creator/steps/StepOverview.svelte b/src/routes/private/events/creator/steps/StepOverview.svelte
deleted file mode 100644
index 8f23aa9..0000000
--- a/src/routes/private/events/creator/steps/StepOverview.svelte
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
-
Event Overview
-
- Name: {event.name}
- Date: {event.date}
-
-
-
-
-
-
Email Preview
-
Subject: {email.subject}
-
-
-
-
-
-
Participants ({participants.length})
-
- {#each participants.slice(0, 10) as p}
-
- {p.name} {p.surname}
-
- {p.email}
-
- {/each}
-
-
Note: Only the first 10 participants are shown.
-
-
-
- {isAddingParticipants ? 'Add participants and send emails' : 'Generate QR codes and send'}
-
-
-
- {#if !stepConditions[0]}
-
Please provide an event name before proceeding.
- {/if}
- {#if !stepConditions[1]}
-
Please add at least one participant before proceeding.
- {/if}
- {#if !stepConditions[2]}
-
Please provide an email subject before proceeding.
- {/if}
- {#if !stepConditions[3]}
-
Please provide an email body before proceeding.
- {/if}
-
diff --git a/src/routes/private/events/creator/steps/StepUploadParticipants.svelte b/src/routes/private/events/creator/steps/StepUploadParticipants.svelte
deleted file mode 100644
index d973d4f..0000000
--- a/src/routes/private/events/creator/steps/StepUploadParticipants.svelte
+++ /dev/null
@@ -1,158 +0,0 @@
-
-
-{#if showForm}
- {#if errors.length > 0}
-
-
- {#each errors as err}
- {err}
- {/each}
-
-
- {/if}
-
- Upload Participants
-
- CSV File
-
-
-
- Submit
-
-
-{:else}
-
- Edit participants
-
-{/if}
-
-{#if !showForm}
-
-
-
Participants ({participants.length})
- {#if participants.length > 0}
- {#if participants.some((p) => !p.email_valid)}
- Some emails appear invalid
- {:else}
- All emails appear valid
- {/if}
- {/if}
-
-
-
- {#each participants as p}
-
- {#if p.email_valid}
-
-
-
- {:else}
-
-
-
-
-
- {/if}
- {p.name} {p.surname}
-
- {p.email}
-
- {/each}
-
-
-{/if}
diff --git a/src/routes/private/events/event/+page.svelte b/src/routes/private/events/event/+page.svelte
deleted file mode 100644
index fbe63d5..0000000
--- a/src/routes/private/events/event/+page.svelte
+++ /dev/null
@@ -1,162 +0,0 @@
-
-
-Event Overview
-
-
-
-
- {#if loading}
-
-
- {:else}
-
{event_data?.name}
-
{event_data?.date}
- {/if}
-
- {#if loading}
-
- {:else if event_data}
-
- Add Participants
-
- {/if}
-
-
-
-
-
-
-
-
- {#if loading}
-
- {:else}
-
Scanned ({scannedCount})
- {/if}
-
-
-
-
-
-
-
-
- {#if loading}
-
- {:else}
-
Not scanned ({notScannedCount})
- {/if}
-
-
-
-
-
- {#if loading}
-
- {:else}
- Participants ({participants.length})
- {/if}
-
-
- {#if loading}
-
-
-
- {:else}
- {#each participants as p}
-
- {#if p.scanned}
-
-
-
- {:else}
-
-
-
-
-
- {/if}
- {p.name} {p.surname}
-
- {#if p.scanned_by}
-
-
- {new Date(p.scanned_at).toLocaleTimeString([], {
- hour: '2-digit',
- minute: '2-digit',
- hour12: false
- })}
-
- by {p.scanned_by.display_name}
-
- {/if}
-
- {/each}
- {/if}
-
-
diff --git a/src/routes/private/events/event/archived/+page.svelte b/src/routes/private/events/event/archived/+page.svelte
new file mode 100644
index 0000000..37cdb56
--- /dev/null
+++ b/src/routes/private/events/event/archived/+page.svelte
@@ -0,0 +1,88 @@
+
+
+Archived Event Overview
+
+
+
+ {#if loading}
+
+
+ {:else}
+
{event_data?.name}
+
{event_data?.date}
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if loading}
+
+ {:else}
+
Total participants ({event_data?.total_participants})
+ {/if}
+
+
+
+
+
+
+ {#if loading}
+
+ {:else}
+
Scanned participants ({event_data?.scanned_participants})
+ {/if}
+
+
+
diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte
new file mode 100644
index 0000000..3d9f809
--- /dev/null
+++ b/src/routes/private/events/event/new/+page.svelte
@@ -0,0 +1,458 @@
+
+
+
+
+
+
+
+
+ {#if currentStep === 0}
+
{
+ authData.error = null;
+ authData.token = token;
+ authData.isConnected = true;
+ setTimeout(checkGoogleAuth, 100);
+ }}
+ onError={(error) => {
+ authData.error = error;
+ authData.isConnected = false;
+ }}
+ />
+ {:else if currentStep === 1}
+
+ {:else if currentStep === 2}
+
+ {:else if currentStep === 3}
+
+ {/if}
+
+ {#if errors.submit}
+
+ {/if}
+
+
+
+
+
diff --git a/src/routes/private/events/event/new/components/EmailSettingsStep.svelte b/src/routes/private/events/event/new/components/EmailSettingsStep.svelte
new file mode 100644
index 0000000..3daf494
--- /dev/null
+++ b/src/routes/private/events/event/new/components/EmailSettingsStep.svelte
@@ -0,0 +1,43 @@
+
+
+
+
+
+ Email Subject *
+
+
+ {#if errors.subject}
+
{errors.subject}
+ {/if}
+
+
+
+
+ Email Body *
+
+
+ {#if errors.body}
+
{errors.body}
+ {/if}
+
+
diff --git a/src/routes/private/events/event/new/components/EventDetailsStep.svelte b/src/routes/private/events/event/new/components/EventDetailsStep.svelte
new file mode 100644
index 0000000..f88218b
--- /dev/null
+++ b/src/routes/private/events/event/new/components/EventDetailsStep.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+
Event details
+
+
+ Event Name *
+
+
+ {#if errors.name}
+
{errors.name}
+ {/if}
+
+
+
+
+ Event Date *
+
+
+ {#if errors.date}
+
{errors.date}
+ {/if}
+
+
diff --git a/src/routes/private/events/event/new/components/GoogleAuthStep.svelte b/src/routes/private/events/event/new/components/GoogleAuthStep.svelte
new file mode 100644
index 0000000..05199b8
--- /dev/null
+++ b/src/routes/private/events/event/new/components/GoogleAuthStep.svelte
@@ -0,0 +1,32 @@
+
+
+
+
+
Connect Your Google Account
+
+ To create events and import participants from Google Sheets, you need to connect your Google account.
+
+
+
+
+ {#if errors.google}
+
+ {errors.google}
+
+ {/if}
+
+
diff --git a/src/routes/private/events/event/new/components/GoogleSheetsStep.svelte b/src/routes/private/events/event/new/components/GoogleSheetsStep.svelte
new file mode 100644
index 0000000..799fc0a
--- /dev/null
+++ b/src/routes/private/events/event/new/components/GoogleSheetsStep.svelte
@@ -0,0 +1,382 @@
+
+
+
+
+
Select Google Sheet
+
+ {#if sheetsData.loading && sheetsData.availableSheets.length === 0}
+
+ {#each Array(5) as _}
+
+ {/each}
+
+ {:else if sheetsData.availableSheets.length === 0}
+
+
No Google Sheets found.
+
+ Refresh
+
+
+ {:else}
+
+ {#if !sheetsData.expandedSheetList && sheetsData.selectedSheet}
+
+
+
+
{sheetsData.selectedSheet.name}
+
+ Modified: {new Date(sheetsData.selectedSheet.modifiedTime).toLocaleDateString()}
+
+
+
+ Change
+
+
+
+
+
+ {:else}
+
+
+
Google Sheets
+ {#if sheetsData.selectedSheet}
+
+ Collapse list
+
+ {/if}
+
+
+
+
+
+
+ {#if searchQuery}
+
+
+
+
+
+ {/if}
+
+
+ {#if isSearching}
+
+
+ {#each Array(3) as _}
+
+ {/each}
+
+ {:else if searchQuery && searchResults.length === 0 && !searchError}
+
+
+
No sheets found matching "{searchQuery}"
+
+ {:else if searchError}
+
+
+
{searchError}
+
+ Retry
+
+
+ {:else if searchQuery && searchResults.length > 0}
+
+
+ {#each searchResults as sheet}
+
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' : ''
+ }"
+ >
+
+ {#if searchQuery}
+ {#each sheet.name.split(new RegExp(`(${searchQuery})`, 'i')) as part}
+ {#if part.toLowerCase() === searchQuery.toLowerCase()}
+ {part}
+ {:else}
+ {part}
+ {/if}
+ {/each}
+ {:else}
+ {sheet.name}
+ {/if}
+
+
+ Modified: {new Date(sheet.modifiedTime).toLocaleDateString('en-GB', {day: '2-digit', month: '2-digit', year: 'numeric'})}
+
+
+ {/each}
+
+ {:else}
+
+
+ {#each sheetsData.availableSheets as sheet}
+
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' : ''
+ }"
+ >
+ {sheet.name}
+
+ Modified: {new Date(sheet.modifiedTime).toLocaleDateString('en-GB', {day: '2-digit', month: '2-digit', year: 'numeric'})}
+
+
+ {/each}
+
+ {#if sheetsData.availableSheets.length === 0 && !sheetsData.loading}
+
+
No recent sheets found. Try searching above.
+
+ {/if}
+ {/if}
+ {/if}
+
+ {/if}
+
+ {#if errors.sheet}
+
{errors.sheet}
+ {/if}
+
+
+ {#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0}
+
+
Column Mapping
+
+
+
+
Column Mapping Instructions:
+
+ Select what each column represents by using the dropdown in each column header.
+ Make sure to assign Name, Surname, Email, and Confirmation columns.
+
+
+
+
+
+
+
+ {#each sheetsData.sheetData[0] || [] as header, index}
+
+
+
+ {header || `Empty Column ${index + 1}`}
+
+
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;
+ }}
+ >
+ Select data type
+ Name
+ Surname
+ Email
+ Confirmation
+
+
+ {#if sheetsData.columnMapping.name === index + 1}
+ Name Column
+ {:else if sheetsData.columnMapping.surname === index + 1}
+ Surname Column
+ {:else if sheetsData.columnMapping.email === index + 1}
+ Email Column
+ {:else if sheetsData.columnMapping.confirmation === index + 1}
+ Confirmation Column
+ {:else}
+ Not Mapped
+ {/if}
+
+
+
+ {/each}
+
+
+
+ {#each sheetsData.sheetData.slice(0, 10) as row, rowIndex}
+
+ {#each row as cell, cellIndex}
+
+
+ {cell || ''}
+
+
+ {/each}
+
+ {/each}
+
+
+
+
+
Showing first 10 rows
+ {#if sheetsData.sheetData[0] && sheetsData.sheetData[0].length > 3}
+
+
+
+
+ Scroll horizontally to see all {sheetsData.sheetData[0].length} columns
+
+ {/if}
+
+
+ {/if}
+
+ {#if sheetsData.loading && sheetsData.selectedSheet}
+
+
Loading sheet data...
+
+ {/if}
+
+ {#if errors.sheetData}
+
{errors.sheetData}
+ {/if}
+
+
+
diff --git a/src/routes/private/events/event/new/components/StepNavigation.svelte b/src/routes/private/events/event/new/components/StepNavigation.svelte
new file mode 100644
index 0000000..207caba
--- /dev/null
+++ b/src/routes/private/events/event/new/components/StepNavigation.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+ Previous
+
+
+
+ {#if currentStep < totalSteps - 1}
+
+ Next
+
+ {:else}
+
+ {loading ? 'Creating...' : 'Create Event'}
+
+ {/if}
+
+
diff --git a/src/routes/private/events/event/new/components/StepNavigator.svelte b/src/routes/private/events/event/new/components/StepNavigator.svelte
new file mode 100644
index 0000000..f26fb87
--- /dev/null
+++ b/src/routes/private/events/event/new/components/StepNavigator.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+ {#each Array(totalSteps) as _, index}
+
+
+ {index + 1}
+
+ {#if index < totalSteps - 1}
+
+ {/if}
+
+ {/each}
+
+
diff --git a/src/routes/private/events/event/view/+page.svelte b/src/routes/private/events/event/view/+page.svelte
new file mode 100644
index 0000000..51fd7f2
--- /dev/null
+++ b/src/routes/private/events/event/view/+page.svelte
@@ -0,0 +1,608 @@
+
+
+
+
+
Event Overview
+
+
+
+
+ {#if loading}
+
+
+ {:else if event}
+
+
+
{event.name}
+
+
+ Date:
+ {formatDate(event.date)}
+
+
+ Created:
+ {formatDate(event.created_at)}
+
+
+
+
+
+
Email Details
+
+
+
Subject:
+
{event.email_subject}
+
+
+
Body:
+
{event.email_body}
+
+
+
+
+ {:else if error}
+
+ {/if}
+
+
+
+
+
+
Google Account
+
Required for syncing participants and sending emails
+
+
{
+ // Refresh the page or update UI state as needed
+ error = '';
+ }}
+ onError={(errorMsg) => {
+ error = errorMsg;
+ }}
+ />
+
+
+
+
+
+
Participants
+
+ {#if syncingParticipants}
+ Syncing...
+ {:else}
+ Sync Participants
+ {/if}
+
+
+
+ {#if participantsLoading}
+
+
+ {#each Array(5) as _}
+
+ {/each}
+
+ {:else if participants.length > 0}
+
+
+
+
+ Name
+ Surname
+ Email
+ Scanned
+ Email Sent
+
+
+
+ {#each participants as participant}
+
+ {participant.name}
+ {participant.surname}
+ {participant.email}
+
+ {#if participant.scanned}
+
+ {:else}
+
+ {/if}
+
+
+ {#if participant.email_sent}
+
+ {:else}
+
+ {/if}
+
+
+ {/each}
+
+
+
+ {:else}
+
+
+ No participants found. Click "Sync Participants" to load from Google Sheets.
+
+
+ {/if}
+
+
+
+
+
+
Send Emails
+
+ {participants.filter(p => !p.email_sent).length} uncontacted participants
+
+
+
+ {#if sendingEmails}
+
+
+
+
+
+
+
Sending {emailProgress.total} emails... Please wait.
+
+
+ {:else}
+
+ {#if participants.filter(p => !p.email_sent).length > 0}
+
+
+
+
+
+
+ Warning: Do not close this window while emails are being sent. The process may take several minutes.
+
+
+
+
+
+ !p.email_sent).length === 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
+
+
+ {:else}
+
+
+
All participants have been contacted!
+
No pending emails to send.
+
+ {/if}
+
+ {/if}
+
+
+
+{#if emailResults}
+
+
+
Email Results
+
+ {emailResults.summary.success} successful, {emailResults.summary.errors} failed
+
+
+
+
+
+
+
+
Sent: {emailResults.summary.success}
+
+
+
+
Failed: {emailResults.summary.errors}
+
+
+
+
Total: {emailResults.summary.total}
+
+
+
+
+ {#if emailResults.results.length > 0}
+
+
+
+
+ Name
+ Email
+ Status
+
+
+
+ {#each emailResults.results as result}
+
+
+ {result.participant.name} {result.participant.surname}
+
+ {result.participant.email}
+
+ {#if result.success}
+
+ {:else}
+
+
+
+
+
Failed
+ {#if result.error}
+
({result.error})
+ {/if}
+
+ {/if}
+
+
+ {/each}
+
+
+
+ {/if}
+
+{/if}
+
+{#if error}
+
+{/if}
diff --git a/src/routes/private/scanner/+page.svelte b/src/routes/private/scanner/+page.svelte
index 10b236d..8220dab 100644
--- a/src/routes/private/scanner/+page.svelte
+++ b/src/routes/private/scanner/+page.svelte
@@ -1,36 +1,153 @@
-
+
+
Code Scanner
-
+
+
+
Select Event
+ {#if isLoadingEvents}
+
+ {:else if eventsError}
+
+ {eventsError}
+
+ Try again
+
+
+ {:else if events.length === 0}
+
No events found
+ {:else}
+
+ {#each events as event}
+
+ {event.name} ({new Date(event.date).toLocaleDateString('en-GB')})
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+
Ticket Information
+
+
+
+ {#if scan_state !== ScanState.scanning}
+
+ {
+ scanned_id = "";
+ scan_state = ScanState.scanning;
+ }}
+ class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold py-2 px-6 rounded-lg transition"
+ aria-label="Reset scanner"
+ >
+ Reset Scanner
+
+
+ {/if}
+
diff --git a/src/routes/private/scanner/QRScanner.svelte b/src/routes/private/scanner/QRScanner.svelte
index d8a520f..fe8cd90 100644
--- a/src/routes/private/scanner/QRScanner.svelte
+++ b/src/routes/private/scanner/QRScanner.svelte
@@ -45,7 +45,7 @@
});
-
+