Compare commits

39 Commits

Author SHA1 Message Date
Roman Krček
e616165a77 Fix service worker mess 2025-09-03 10:38:30 +02:00
Roman Krček
238d2eebc5 Fix worker reloads 2025-09-03 10:22:59 +02:00
Roman Krček
aedf260551 Fixed problem where auth is bypassed 2025-09-03 10:17:20 +02:00
Roman Krček
f1179ddc09 Fix when people order multiple times 2025-09-03 08:34:22 +02:00
Roman Krček
7b4a179428 Fix diff counting logic 2025-09-02 19:35:49 +02:00
Roman Krček
5ef9735ea5 Fixed hardcoded range 2025-09-02 19:30:33 +02:00
Roman Krček
03eeef5c69 Updated google button 2025-09-02 19:16:52 +02:00
Roman Krček
fa685e6ba9 Added loading indicator 2025-09-02 18:21:18 +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
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
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
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
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
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
45 changed files with 1907 additions and 725 deletions

View File

@@ -1,11 +1,20 @@
GitHub Copilot Instructions for This Repository GitHub Copilot Instructions for This Repository
Basics: Basics: These you need to really follow!
- If you have any questions, always ask me first! - If you have any questions, always ask me first!
- Use Svelte 5 runes exclusively - Use Svelte 5 runes exclusively
- Declare reactive state with $state(); derive values with $derived(); run side-effect logic with $effect() etc. - 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. - 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. - 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! Do not fall back to the legacy $: label syntax or Svelte 3/4 stores! This is important!

27
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0", "@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@tanstack/svelte-query": "^5.83.0",
"googleapis": "^150.0.1", "googleapis": "^150.0.1",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -1382,6 +1383,32 @@
"vite": "^5.2.0 || ^6" "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": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "scan-wave", "name": "scan-wave",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -33,6 +33,7 @@
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0", "@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@tanstack/svelte-query": "^5.83.0",
"googleapis": "^150.0.1", "googleapis": "^150.0.1",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"qrcode": "^1.5.4", "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 { Session, SupabaseClient, User } from '@supabase/supabase-js'
import type { Database } from './database.types.ts' // import generated types 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 { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
supabase: SupabaseClient<Database> supabase: SupabaseClient<Database>
safeGetSession: () => Promise<{ session: Session | null; user: User | null }> safeGetSession: () => Promise<{ session: Session | null; user: User | null }>
getUserProfile: (userId: string) => Promise<Profile | null>
session: Session | null session: Session | null
user: User | null user: User | null
profile: Profile | null
} }
interface PageData { interface PageData {
session: Session | null session: Session | null
user: User | null
profile: Profile | null
} }
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
</head> </head>
@@ -16,5 +16,4 @@
body { body {
font-family: "Roboto", sans-serif; font-family: "Roboto", sans-serif;
} }
</style> </style>

View File

@@ -51,6 +51,22 @@ const supabase: Handle = async ({ event, resolve }) => {
return { session, user } 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, { return resolve(event, {
filterSerializedResponseHeaders(name) { filterSerializedResponseHeaders(name) {
/** /**
@@ -67,14 +83,26 @@ const authGuard: Handle = async ({ event, resolve }) => {
event.locals.session = session event.locals.session = session
event.locals.user = user 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')) { if (!event.locals.session && event.url.pathname.startsWith('/private')) {
redirect(303, '/auth') redirect(303, '/auth/login')
} }
if (event.locals.session && event.url.pathname === '/auth') { if (event.locals.session && event.url.pathname === '/auth') {
redirect(303, '/private/home') 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) return resolve(event)
} }

View File

@@ -6,12 +6,14 @@
let { let {
onSuccess, onSuccess,
onError, onError,
onDisconnect,
disabled = false, disabled = false,
size = 'default', size = 'default',
variant = 'primary' variant = 'primary'
} = $props<{ } = $props<{
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onDisconnect?: () => void;
disabled?: boolean; disabled?: boolean;
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
variant?: 'primary' | 'secondary'; variant?: 'primary' | 'secondary';
@@ -21,8 +23,8 @@
let authState = $state(createGoogleAuthState()); let authState = $state(createGoogleAuthState());
let authManager = new GoogleAuthManager(authState); let authManager = new GoogleAuthManager(authState);
onMount(() => { onMount(async () => {
authManager.checkConnection(); await authManager.checkConnection();
}); });
async function handleConnect() { async function handleConnect() {
@@ -41,6 +43,7 @@
async function handleDisconnect() { async function handleDisconnect() {
await authManager.disconnectGoogle(); await authManager.disconnectGoogle();
onDisconnect?.();
} }
// Size classes // Size classes
@@ -57,7 +60,14 @@
}; };
</script> </script>
{#if authState.isConnected} {#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 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"> <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"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-600" viewBox="0 0 20 20" fill="currentColor">

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

@@ -30,7 +30,7 @@ export class GoogleAuthManager {
this.state = state; this.state = state;
} }
checkConnection(): void { async checkConnection(): Promise<void> {
this.state.checking = true; this.state.checking = true;
this.state.error = null; this.state.error = null;
@@ -38,12 +38,39 @@ export class GoogleAuthManager {
const token = localStorage.getItem('google_refresh_token'); const token = localStorage.getItem('google_refresh_token');
const email = localStorage.getItem('google_user_email'); const email = localStorage.getItem('google_user_email');
this.state.isConnected = !!token; if (!token) {
this.state.token = token; this.state.isConnected = false;
this.state.userEmail = email; 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) { } catch (error) {
console.error('Error checking connection:', error); console.error('Error checking connection:', error);
this.state.error = 'Failed to check connection status'; this.state.error = 'Failed to verify connection status';
this.state.isConnected = false;
} finally { } finally {
this.state.checking = false; this.state.checking = false;
} }

View File

@@ -32,7 +32,9 @@ export async function getRecentSpreadsheets(
q: "mimeType='application/vnd.google-apps.spreadsheet'", q: "mimeType='application/vnd.google-apps.spreadsheet'",
orderBy: 'modifiedTime desc', orderBy: 'modifiedTime desc',
pageSize: limit, pageSize: limit,
fields: 'files(id,name,modifiedTime,webViewLink)' fields: 'files(id,name,modifiedTime,webViewLink)',
includeItemsFromAllDrives: true,
supportsAllDrives: true
}); });
return ( return (
@@ -49,25 +51,48 @@ export async function getRecentSpreadsheets(
* Get data from a Google Sheet * Get data from a Google Sheet
* @param refreshToken - Google refresh token * @param refreshToken - Google refresh token
* @param spreadsheetId - ID of the spreadsheet * @param spreadsheetId - ID of the spreadsheet
* @param range - Cell range to retrieve (default: A1:Z10) * @param range - Optional cell range. If not provided, it will fetch the entire first sheet.
* @returns Sheet data as a 2D array * @returns Sheet data as a 2D array
*/ */
export async function getSpreadsheetData( export async function getSpreadsheetData(
refreshToken: string, refreshToken: string,
spreadsheetId: string, spreadsheetId: string,
range: string = 'A1:Z10' range?: string
): Promise<SheetData> { ): Promise<SheetData> {
const oauth = getAuthenticatedClient(refreshToken); const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth }); const sheets = google.sheets({ version: 'v4', auth: oauth });
const response = await sheets.spreadsheets.values.get({ let effectiveRange = range;
spreadsheetId,
range
});
return { // If no range is provided, get the name of the first sheet and use that as the range
values: response.data.values || [] // to fetch all its content.
}; if (!effectiveRange) {
try {
const info = await getSpreadsheetInfo(refreshToken, spreadsheetId);
const firstSheetName = info.sheets?.[0]?.properties?.title;
if (firstSheetName) {
// To use a sheet name as a range, it must be quoted if it contains spaces or special characters.
effectiveRange = `'${firstSheetName}'`;
} else {
// Fallback if sheet name can't be determined.
effectiveRange = 'A1:Z1000'; // A sensible default for a large preview
}
} catch (error) {
console.error(`Failed to get sheet info for spreadsheet ${spreadsheetId}`, error);
// Fallback if the info call fails
effectiveRange = 'A1:Z1000';
}
}
const response = await sheets.spreadsheets.values.get({
spreadsheetId,
range: effectiveRange
});
return {
values: response.data.values || []
};
} }
/** /**
@@ -113,7 +138,9 @@ export async function searchSheets(
q, q,
orderBy: 'modifiedTime desc', orderBy: 'modifiedTime desc',
pageSize: limit, pageSize: limit,
fields: 'files(id,name,modifiedTime,webViewLink)' fields: 'files(id,name,modifiedTime,webViewLink)',
includeItemsFromAllDrives: true,
supportsAllDrives: true
}); });
return ( return (

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

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

View File

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

View File

@@ -39,5 +39,10 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => {
data: { user }, data: { user },
} = await supabase.auth.getUser() } = 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"> <div class="min-h-screen flex flex-col justify-center items-center">
<!-- SVG QR Code Art on Top -->
<div class="mb-8"> <div class="mb-8">
<img class="w-32 h-auto" src="/qr-code.png" alt=""> <img class="w-32 h-auto" src="/qr-code.png" alt="">
</div> </div>

View File

@@ -1,21 +1,44 @@
<script lang="ts"> <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> </script>
<nav class="bg-gray-50 border-b border-gray-300 text-gray-900 p-2"> <nav class="border-b border-gray-300 bg-gray-50 p-2 text-gray-900">
<div class="container max-w-2xl mx-auto p-2"> <div class="container mx-auto max-w-2xl p-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-bold text-lg">ScanWave</div> <a href="/private/home" class="text-lg font-bold" aria-label="ScanWave Home">ScanWave</a>
<ul class="flex space-x-4"> <ul class="flex space-x-4">
<li><a href="/private/home" class="hover:underline">Home</a></li> <li><a href="/private/scanner">Scanner</a></li>
<li><a href="/private/scanner" class="hover:underline">Scanner</a></li> {#if data.profile?.section_position === 'events_manager'}
<li><a href="/private/events" class="hover:underline">Events</a></li> <li><a href="/private/events">Events</a></li>
</ul> {/if}
</div> </ul>
</div> </div>
</div>
</nav> </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> </div>
<ToastContainer />

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

@@ -16,6 +16,16 @@ interface EmailResult {
error?: string; 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> { async function generateQRCode(participantId: string): Promise<string> {
const qrCodeBase64 = await QRCode.toDataURL(participantId, { const qrCodeBase64 = await QRCode.toDataURL(participantId, {
type: 'image/png', type: 'image/png',
@@ -38,11 +48,15 @@ async function sendEmailToParticipant(
try { try {
const qrCodeBase64Data = await generateQRCode(participant.id); 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 // Send email with QR code
await sendGmail(refreshToken, { await sendGmail(refreshToken, {
to: participant.email, to: participant.email,
subject: subject, subject: personalizedSubject,
text: text, text: personalizedText,
qr_code: qrCodeBase64Data qr_code: qrCodeBase64Data
}); });

View File

@@ -2,17 +2,18 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { googleSheetsServer } from '$lib/google/sheets/server.js'; import { googleSheetsServer } from '$lib/google/sheets/server.js';
export const GET: RequestHandler = async ({ params, request }) => { export const GET: RequestHandler = async ({ params, request, url }) => {
try { try {
const { sheetId } = params; const { sheetId } = params;
const authHeader = request.headers.get('authorization'); const authHeader = request.headers.get('authorization');
const range = url.searchParams.get('range') || undefined;
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); return json({ error: 'Missing or invalid authorization header' }, { status: 401 });
} }
const refreshToken = authHeader.slice(7); const refreshToken = authHeader.slice(7);
const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, range);
return json(sheetData); return json(sheetData);
} catch (error) { } catch (error) {

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

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { isTokenValid, getUserInfo, revokeToken } from '$lib/google/auth/client.js';
import type { GoogleSheet } from '$lib/google/sheets/types.ts'; import type { GoogleSheet } from '$lib/google/sheets/types.ts';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { toast } from '$lib/stores/toast.js';
// Import Components // Import Components
import GoogleAuthStep from './components/GoogleAuthStep.svelte'; import GoogleAuthStep from './components/GoogleAuthStep.svelte';
import EventDetailsStep from './components/EventDetailsStep.svelte'; import EventDetailsStep from './components/EventDetailsStep.svelte';
@@ -15,19 +15,11 @@
let { data } = $props(); let { data } = $props();
// Step management // Step management
let currentStep = $state(0); // Start at step 0 for Google auth check let currentStep = $state(0);
const totalSteps = 4; // Increased to include auth step const totalSteps = 4;
// Step 0: Google Auth // Step 0: Google Auth
let authData = $state({ let isGoogleConnected = $state(false);
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 // Step 1: Event Details
let eventData = $state({ let eventData = $state({
@@ -41,13 +33,13 @@
selectedSheet: null as GoogleSheet | null, selectedSheet: null as GoogleSheet | null,
sheetData: [] as string[][], sheetData: [] as string[][],
columnMapping: { columnMapping: {
name: 0, // Initialize to 0 (no column selected) name: 0,
surname: 0, surname: 0,
email: 0, email: 0,
confirmation: 0 confirmation: 0
}, },
loading: false, loading: false,
expandedSheetList: true // Add this flag to control sheet list expansion expandedSheetList: true
}); });
// Step 3: Email // Step 3: Email
@@ -61,189 +53,11 @@
let errors = $state<Record<string, string>>({}); let errors = $state<Record<string, string>>({});
onMount(async () => { onMount(async () => {
// Check Google auth status on mount
await checkGoogleAuth();
if (currentStep === 2) { if (currentStep === 2) {
await loadRecentSheets(); 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 // Step navigation
function nextStep() { function nextStep() {
if (validateCurrentStep()) { if (validateCurrentStep()) {
@@ -259,49 +73,65 @@
} }
function validateCurrentStep(): boolean { function validateCurrentStep(): boolean {
// Clear previous errors
errors = {}; errors = {};
let isValid = true;
if (currentStep === 0) { if (currentStep === 0) {
if (!authData.isConnected) { if (!isGoogleConnected) {
toast.error('Please connect your Google account to continue');
errors.auth = 'Please connect your Google account to continue'; errors.auth = 'Please connect your Google account to continue';
return false; return false;
} }
} else if (currentStep === 1) { } else if (currentStep === 1) {
if (!eventData.name.trim()) { if (!eventData.name.trim()) {
toast.error('Event name is required');
errors.name = 'Event name is required'; errors.name = 'Event name is required';
isValid = false;
} }
if (!eventData.date) { if (!eventData.date) {
toast.error('Event date is required');
errors.date = 'Event date is required'; errors.date = 'Event date is required';
isValid = false;
} }
} else if (currentStep === 2) { } else if (currentStep === 2) {
if (!sheetsData.selectedSheet) { if (!sheetsData.selectedSheet) {
toast.error('Please select a Google Sheet');
errors.sheet = 'Please select a Google Sheet'; errors.sheet = 'Please select a Google Sheet';
isValid = false;
} }
if (sheetsData.selectedSheet) { if (sheetsData.selectedSheet) {
// Validate column mappings // Validate column mappings
const { name, surname, email, confirmation } = sheetsData.columnMapping; const { name, surname, email, confirmation } = sheetsData.columnMapping;
const missingColumns = []; const missingColumns = [];
if (!name) missingColumns.push('Name'); if (!name) missingColumns.push('Name');
if (!surname) missingColumns.push('Surname'); if (!surname) missingColumns.push('Surname');
if (!email) missingColumns.push('Email'); if (!email) missingColumns.push('Email');
if (!confirmation) missingColumns.push('Confirmation'); if (!confirmation) missingColumns.push('Confirmation');
if (missingColumns.length > 0) { if (missingColumns.length > 0) {
errors.sheetData = `Please map the following columns: ${missingColumns.join(', ')}`; const errorMsg = `Please map the following columns: ${missingColumns.join(', ')}`;
toast.error(errorMsg);
errors.sheetData = errorMsg;
isValid = false;
} }
} }
} else if (currentStep === 3) { } else if (currentStep === 3) {
if (!emailData.subject.trim()) { if (!emailData.subject.trim()) {
toast.error('Email subject is required');
errors.subject = 'Email subject is required'; errors.subject = 'Email subject is required';
isValid = false;
} }
if (!emailData.body.trim()) { if (!emailData.body.trim()) {
toast.error('Email body is required');
errors.body = 'Email body is required'; errors.body = 'Email body is required';
isValid = false;
} }
} }
return Object.keys(errors).length === 0; return isValid;
} }
// Google Sheets functions // Google Sheets functions
@@ -309,16 +139,16 @@
sheetsData.loading = true; sheetsData.loading = true;
// Always expand the sheet list when loading new sheets // Always expand the sheet list when loading new sheets
sheetsData.expandedSheetList = true; sheetsData.expandedSheetList = true;
try { try {
// Use the new unified API endpoint // Use the new unified API endpoint
const response = await fetch('/private/api/google/sheets/recent', { const response = await fetch('/private/api/google/sheets/recent', {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}` Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
} }
}); });
if (response.ok) { if (response.ok) {
sheetsData.availableSheets = await response.json(); sheetsData.availableSheets = await response.json();
} }
@@ -332,24 +162,24 @@
async function selectSheet(sheet: GoogleSheet) { async function selectSheet(sheet: GoogleSheet) {
const sameSheet = sheetsData.selectedSheet?.id === sheet.id; const sameSheet = sheetsData.selectedSheet?.id === sheet.id;
sheetsData.selectedSheet = sheet; sheetsData.selectedSheet = sheet;
sheetsData.loading = true; sheetsData.loading = true;
// Collapse sheet list when selecting a new sheet // Collapse sheet list when selecting a new sheet
if (!sameSheet) { if (!sameSheet) {
sheetsData.expandedSheetList = false; sheetsData.expandedSheetList = false;
} }
try { try {
// Use the new unified API endpoint // Use the new unified API endpoint, requesting only a preview range
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, { const response = await fetch(`/private/api/google/sheets/${sheet.id}/data?range=A1:Z10`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}` Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
} }
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
sheetsData.sheetData = data.values || []; sheetsData.sheetData = data.values || [];
@@ -361,19 +191,32 @@
sheetsData.loading = false; sheetsData.loading = false;
} }
} }
// Toggle the sheet list expansion // Toggle the sheet list expansion
function toggleSheetList() { function toggleSheetList() {
sheetsData.expandedSheetList = !sheetsData.expandedSheetList; 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 // Final submission
async function createEvent() { async function createEvent() {
if (!validateCurrentStep()) return; if (!validateCurrentStep()) return;
loading = true; loading = true;
try { try {
const { error } = await data.supabase.rpc('create_event', { const { data: newEvent, error } = await data.supabase.rpc('create_event', {
p_name: eventData.name, p_name: eventData.name,
p_date: eventData.date, p_date: eventData.date,
p_email_subject: emailData.subject, p_email_subject: emailData.subject,
@@ -387,11 +230,19 @@
if (error) throw error; if (error) throw error;
// Redirect to events list or show success message // Display success message
goto('/private/events'); 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) { } catch (error) {
console.error('Error creating event:', error); console.error('Error creating event:', error);
errors.submit = 'Failed to create event. Please try again.'; toast.error('Failed to create event. Please try again.');
} finally { } finally {
loading = false; loading = false;
} }
@@ -399,60 +250,50 @@
// Computed values // Computed values
let canProceed = $derived(() => { let canProceed = $derived(() => {
if (currentStep === 0) return authData.isConnected; if (currentStep === 0) return isGoogleConnected;
if (currentStep === 1) return eventData.name && eventData.date; if (currentStep === 1) return !!(eventData.name && eventData.date);
if (currentStep === 2) { if (currentStep === 2) {
const { name, surname, email, confirmation } = sheetsData.columnMapping; const { name, surname, email, confirmation } = sheetsData.columnMapping;
return sheetsData.selectedSheet && name && surname && email && confirmation; return !!(sheetsData.selectedSheet && name && surname && email && confirmation);
} }
if (currentStep === 3) return emailData.subject && emailData.body; if (currentStep === 3) return !!(emailData.subject && emailData.body);
return false; return false;
}); });
</script> </script>
<div class="max-w-4xl mx-auto p-6"> <!-- Header -->
<!-- Header --> <StepNavigator {currentStep} {totalSteps} />
<StepNavigator {currentStep} {totalSteps} />
<!-- Step Content --> <!-- Step Content -->
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4"> <div class="mb-4 rounded border border-gray-300 bg-white p-6">
{#if currentStep === 0} {#if currentStep === 0}
<GoogleAuthStep <GoogleAuthStep
bind:errors onSuccess={() => (isGoogleConnected = true)}
onSuccess={(token) => { onDisconnect={() => (isGoogleConnected = false)}
authData.error = null; onError={(err) => toast.error(err)}
authData.token = token; />
authData.isConnected = true; {:else if currentStep === 1}
setTimeout(checkGoogleAuth, 100); <EventDetailsStep bind:eventData />
}} {:else if currentStep === 2}
onError={(error) => { <GoogleSheetsStep
authData.error = error; bind:sheetsData
authData.isConnected = false; {loadRecentSheets}
}} {selectSheet}
/> {toggleSheetList}
{:else if currentStep === 1} {resetSheetSelection}
<EventDetailsStep bind:eventData bind:errors /> />
{:else if currentStep === 2} {:else if currentStep === 3}
<GoogleSheetsStep bind:sheetsData bind:errors {loadRecentSheets} {selectSheet} {toggleSheetList} /> <EmailSettingsStep bind:emailData />
{:else if currentStep === 3} {/if}
<EmailSettingsStep bind:emailData bind:errors />
{/if}
{#if errors.submit}
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded">
<p class="text-sm text-red-600">{errors.submit}</p>
</div>
{/if}
</div>
<!-- Navigation -->
<StepNavigation
{currentStep}
{totalSteps}
{canProceed}
{loading}
{prevStep}
{nextStep}
{createEvent}
/>
</div> </div>
<!-- Navigation -->
<StepNavigation
{currentStep}
{totalSteps}
canProceed={canProceed()}
{loading}
{prevStep}
{nextStep}
{createEvent}
/>

View File

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

View File

@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
let { eventData = $bindable(), errors = $bindable() } = $props<{ let { eventData = $bindable() } = $props<{
eventData: { eventData: {
name: string; name: string;
date: string; date: string;
}; };
errors: Record<string, string>;
}>(); }>();
</script> </script>
@@ -22,9 +21,6 @@
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" 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" placeholder="Enter event name"
/> />
{#if errors.name}
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
{/if}
</div> </div>
<div> <div>
@@ -37,8 +33,5 @@
bind:value={eventData.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" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
{#if errors.date}
<p class="mt-1 text-sm text-red-600">{errors.date}</p>
{/if}
</div> </div>
</div> </div>

View File

@@ -2,10 +2,10 @@
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte'; import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props // Props
let { errors, onSuccess, onError } = $props<{ let { onSuccess, onError, onDisconnect } = $props<{
errors: Record<string, string>;
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onDisconnect?: () => void;
}>(); }>();
</script> </script>
@@ -16,17 +16,12 @@
To create events and import participants from Google Sheets, you need to connect your Google account. To create events and import participants from Google Sheets, you need to connect your Google account.
</p> </p>
<GoogleAuthButton <GoogleAuthButton
size="large" size="large"
variant="primary" variant="primary"
onSuccess={onSuccess} {onSuccess}
onError={onError} {onError}
/> {onDisconnect}
/>
{#if errors.google}
<div class="mt-4 text-sm text-red-600">
{errors.google}
</div>
{/if}
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import type { GoogleSheet } from '$lib/google/sheets/types.ts'; import type { GoogleSheet } from '$lib/google/sheets/types.ts';
// Props // Props
let { sheetsData = $bindable(), errors = $bindable(), loadRecentSheets, selectSheet, toggleSheetList } = $props<{ let { sheetsData = $bindable(), loadRecentSheets, selectSheet, toggleSheetList, resetSheetSelection } = $props<{
sheetsData: { sheetsData: {
availableSheets: GoogleSheet[]; availableSheets: GoogleSheet[];
selectedSheet: GoogleSheet | null; selectedSheet: GoogleSheet | null;
@@ -16,10 +16,10 @@
loading: boolean; loading: boolean;
expandedSheetList: boolean; expandedSheetList: boolean;
}; };
errors: Record<string, string>;
loadRecentSheets: () => Promise<void>; loadRecentSheets: () => Promise<void>;
selectSheet: (sheet: GoogleSheet) => Promise<void>; selectSheet: (sheet: GoogleSheet) => Promise<void>;
toggleSheetList: () => void; toggleSheetList: () => void;
resetSheetSelection: () => void;
}>(); }>();
// Search functionality // Search functionality
@@ -124,13 +124,13 @@
</div> </div>
</div> </div>
<button <button
onclick={toggleSheetList} onclick={resetSheetSelection}
class="text-blue-600 hover:text-blue-800 flex items-center" class="text-blue-600 hover:text-blue-800 flex items-center"
aria-label="Show all sheets" aria-label="Reset selection and show all sheets"
> >
<span class="text-sm mr-1">Change</span> <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"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /> <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> </svg>
</button> </button>
</div> </div>
@@ -256,10 +256,6 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if errors.sheet}
<p class="mt-2 text-sm text-red-600">{errors.sheet}</p>
{/if}
</div> </div>
{#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0} {#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0}
@@ -373,10 +369,6 @@
<div class="text-gray-600">Loading sheet data...</div> <div class="text-gray-600">Loading sheet data...</div>
</div> </div>
{/if} {/if}
{#if errors.sheetData}
<p class="text-sm text-red-600">{errors.sheetData}</p>
{/if}
</div> </div>

View File

@@ -6,7 +6,7 @@
}>(); }>();
</script> </script>
<div class="mb-8"> <div class="mb-8 mt-6">
<div class="flex items-center justify-center gap-4 w-full"> <div class="flex items-center justify-center gap-4 w-full">
{#each Array(totalSteps) as _, index} {#each Array(totalSteps) as _, index}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@@ -9,8 +9,8 @@
import ParticipantsTable from './components/ParticipantsTable.svelte'; import ParticipantsTable from './components/ParticipantsTable.svelte';
import EmailSending from './components/EmailSending.svelte'; import EmailSending from './components/EmailSending.svelte';
import EmailResults from './components/EmailResults.svelte'; import EmailResults from './components/EmailResults.svelte';
import ErrorMessage from './components/ErrorMessage.svelte';
import Statistics from './components/Statistics.svelte'; import Statistics from './components/Statistics.svelte';
import { toast } from '$lib/stores/toast.js';
let { data } = $props(); let { data } = $props();
@@ -47,9 +47,9 @@
let participantsLoading = $state(true); let participantsLoading = $state(true);
let syncingParticipants = $state(false); let syncingParticipants = $state(false);
let sendingEmails = $state(false); let sendingEmails = $state(false);
let updatingEmail = $state(false);
let emailProgress = $state({ sent: 0, total: 0 }); let emailProgress = $state({ sent: 0, total: 0 });
let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null); let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null);
let error = $state('');
// Get event ID from URL params // Get event ID from URL params
let eventId = $derived(page.url.searchParams.get('id')); let eventId = $derived(page.url.searchParams.get('id'));
@@ -74,7 +74,7 @@
event = eventData; event = eventData;
} catch (err) { } catch (err) {
console.error('Error loading event:', err); console.error('Error loading event:', err);
error = 'Failed to load event'; toast.error('Failed to load event');
} finally { } finally {
loading = false; loading = false;
} }
@@ -95,24 +95,28 @@
participants = participantsData || []; participants = participantsData || [];
} catch (err) { } catch (err) {
console.error('Error loading participants:', err); console.error('Error loading participants:', err);
error = 'Failed to load participants'; toast.error('Failed to load participants');
} finally { } finally {
participantsLoading = false; participantsLoading = false;
} }
} }
async function syncParticipants() { async function syncParticipants() {
if (!event || !event.sheet_id) return; 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 // Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token'); const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) { if (!refreshToken) {
error = 'Please connect your Google account first to sync participants'; toast.error('Please connect your Google account first to sync participants');
return; return;
} }
syncingParticipants = true; syncingParticipants = true;
error = ''; const previousCount = participants.length; // Capture count before sync
try { try {
// Fetch sheet data // Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, { const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
@@ -134,35 +138,64 @@
if (rows.length === 0) throw new Error('No data found in sheet'); if (rows.length === 0) throw new Error('No data found in sheet');
// Extract participant data based on column mapping // --- Start of new logic to handle duplicates ---
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
// Skip header row (start from index 1) // First, extract all potential participants from the sheet
const potentialParticipants = [];
for (let i = 1; i < rows.length; i++) { for (let i = 1; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];
if (row.length > 0) { if (row.length > 0) {
const name = row[event.name_column - 1] || ''; const name = row[event.name_column - 1] || '';
const surname = row[event.surname_column - 1] || ''; const surname = row[event.surname_column - 1] || '';
const email = row[event.email_column - 1] || ''; const email = (row[event.email_column - 1] || '').trim();
const confirmation = row[event.confirmation_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 = const isConfirmed =
confirmation.toString().toLowerCase() === 'true' || confirmation.toString().toLowerCase() === 'true' ||
confirmation.toString().toLowerCase() === 'yes' || confirmation.toString().toLowerCase() === 'yes' ||
confirmation === '1' || confirmation === '1' ||
confirmation === 'x'; confirmation === 'x';
if ((name.trim() || surname.trim() || email.trim()) && isConfirmed) { if ((name.trim() || surname.trim() || email) && isConfirmed) {
names.push(name.trim()); potentialParticipants.push({ name: name.trim(), surname: surname.trim(), email });
surnames.push(surname.trim());
emails.push(email.trim());
} }
} }
} }
// Create a map to count occurrences of each unique participant combination
const participantCounts = new Map<string, number>();
for (const p of potentialParticipants) {
const key = `${p.name}|${p.surname}|${p.email}`.toLowerCase(); // Create a unique key
participantCounts.set(key, (participantCounts.get(key) || 0) + 1);
}
// Create final arrays, modifying duplicate surnames to be unique
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
const processedParticipants = new Map<string, number>();
for (const p of potentialParticipants) {
const key = `${p.name}|${p.surname}|${p.email}`.toLowerCase();
let finalSurname = p.surname;
// If this participant is a duplicate
if (participantCounts.get(key)! > 1) {
const count = (processedParticipants.get(key) || 0) + 1;
processedParticipants.set(key, count);
// If it's not the first occurrence, append a counter to the surname
if (count > 1) {
finalSurname = `${p.surname} (${count})`;
}
}
names.push(p.name);
surnames.push(finalSurname);
emails.push(p.email); // Keep the original email
}
// --- End of new logic ---
// Call database function to add participants // Call database function to add participants
const { error: syncError } = await data.supabase.rpc('participants_add_bulk', { const { error: syncError } = await data.supabase.rpc('participants_add_bulk', {
p_event: eventId, p_event: eventId,
@@ -175,9 +208,26 @@
// Reload participants // Reload participants
await loadParticipants(); await loadParticipants();
// Show success message with accurate count of changes
const newCount = participants.length;
const diff = newCount - previousCount;
const processedCount = names.length;
let message = `Sync complete. ${processedCount} confirmed entries processed from the sheet.`;
if (diff > 0) {
message += ` ${diff} new participants added.`;
} else if (diff < 0) {
message += ` ${-diff} participants removed.`;
} else {
message += ` No changes to the participant list.`;
}
toast.success(message, 6000);
} catch (err) { } catch (err) {
console.error('Error syncing participants:', err); console.error('Error syncing participants:', err);
error = 'Failed to sync participants'; toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally { } finally {
syncingParticipants = false; syncingParticipants = false;
} }
@@ -189,20 +239,25 @@
// Check if user has Google authentication // Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token'); const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) { if (!refreshToken) {
error = 'Please connect your Google account first to send emails'; toast.add({
message: 'Please connect your Google account first to send emails',
type: 'error'
});
return; return;
} }
const uncontactedParticipants = participants.filter(p => !p.email_sent); const uncontactedParticipants = participants.filter(p => !p.email_sent);
if (uncontactedParticipants.length === 0) { if (uncontactedParticipants.length === 0) {
error = 'No uncontacted participants found'; toast.add({
message: 'No uncontacted participants found',
type: 'warning'
});
return; return;
} }
sendingEmails = true; sendingEmails = true;
emailProgress = { sent: 0, total: uncontactedParticipants.length }; emailProgress = { sent: 0, total: uncontactedParticipants.length };
emailResults = null; emailResults = null;
error = '';
try { try {
// Send all emails in batch // Send all emails in batch
@@ -235,33 +290,76 @@
}); });
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
error = errorData.error || 'Failed to send emails'; toast.add({
message: errorData.error || 'Failed to send emails',
type: 'error'
});
console.error('Email sending failed:', errorData); console.error('Email sending failed:', errorData);
} }
} catch (err) { } catch (err) {
console.error('Error sending emails:', err); console.error('Error sending emails:', err);
error = 'Failed to send emails to participants'; toast.add({
message: 'Failed to send emails to participants',
type: 'error'
});
} finally { } finally {
sendingEmails = false; 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() { function handleGoogleAuthSuccess() {
error = ''; // Success handled by toast in the component
} }
function handleGoogleAuthError(errorMsg: string) { function handleGoogleAuthError(errorMsg: string) {
error = errorMsg; toast.add({
message: errorMsg,
type: 'error'
});
} }
</script> </script>
<!-- Header -->
<div class="mt-2 mb-4"> <div class="mt-2 mb-4">
<h1 class="text-center text-2xl font-bold">Event Overview</h1> <h1 class="text-center text-2xl font-bold">Event Overview</h1>
</div> </div>
<!-- Composable components --> <EventInformation {event} {loading} />
<EventInformation {event} {loading} {error} />
<GoogleAuthentication <GoogleAuthentication
{loading} {loading}
@@ -289,7 +387,12 @@ onSyncParticipants={syncParticipants}
/> />
</div> </div>
<EmailTemplate {event} {loading} /> <EmailTemplate
{event}
{loading}
{updatingEmail}
onUpdateEmail={handleEmailUpdate}
/>
<EmailSending <EmailSending
{loading} {loading}
@@ -303,7 +406,3 @@ onSyncParticipants={syncParticipants}
{#if emailResults} {#if emailResults}
<EmailResults {emailResults} /> <EmailResults {emailResults} />
{/if} {/if}
{#if error}
<ErrorMessage {error} />
{/if}

View File

@@ -1,18 +1,104 @@
<script lang="ts"> <script lang="ts">
interface Event { interface Event {
id: string;
email_subject: string; email_subject: string;
email_body: string; email_body: string;
} }
let { event, loading } = $props<{ let {
event,
loading,
updatingEmail,
onUpdateEmail
} = $props<{
event: Event | null; event: Event | null;
loading: boolean; 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> </script>
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4"> <div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4"> <div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Email Template</h2> <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> </div>
{#if loading} {#if loading}
@@ -31,17 +117,34 @@
{:else if event} {:else if event}
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<span class="block mb-1 text-sm font-medium text-gray-700">Subject:</span> <label for="emailSubject" class="block mb-1 text-sm font-medium text-gray-700">Subject:</label>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200"> <input
<p class="text-sm text-gray-900">{event.email_subject}</p> id="emailSubject"
</div> 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>
<div> <div>
<span class="block mb-1 text-sm font-medium text-gray-700">Body:</span> <label for="emailBody" class="block mb-1 text-sm font-medium text-gray-700">Body:</label>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200 max-h-48 overflow-y-auto"> <textarea
<p class="text-sm whitespace-pre-wrap text-gray-900">{event.email_body}</p> id="emailBody"
</div> 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> </div>
<!-- Save button moved to the header -->
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -1,11 +1,127 @@
<script lang="ts"> <script lang="ts">
let { error } = $props<{ import { onMount } from 'svelte';
error: string;
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> </script>
{#if error} {#if visible && message}
<div class="mt-4 rounded border border-red-200 bg-red-50 p-3"> <div
<p class="text-sm text-red-600">{error}</p> 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> </div>
{/if} {/if}
<style>
@keyframes progress {
from {
width: 100%;
}
to {
width: 0%;
}
}
</style>

View File

@@ -6,10 +6,9 @@
sheet_id: string; sheet_id: string;
} }
let { event, loading, error } = $props<{ let { event, loading } = $props<{
event: Event | null; event: Event | null;
loading: boolean; loading: boolean;
error: string;
}>(); }>();
function formatDate(dateString: string) { function formatDate(dateString: string) {
@@ -80,9 +79,9 @@
</div> </div>
</div> </div>
</div> </div>
{:else if error} {:else}
<div class="py-8 text-center"> <div class="py-8 text-center">
<p class="text-red-600">{error}</p> <p class="text-gray-600">No event information available</p>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { toast } from '$lib/stores/toast.js';
interface Participant { interface Participant {
id: string; id: string;
name: string; name: string;
@@ -28,13 +30,23 @@
syncingParticipants: boolean; syncingParticipants: boolean;
onSyncParticipants: () => void; 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> </script>
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6"> <div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Participants</h2> <h2 class="text-xl font-semibold text-gray-900">Participants</h2>
<button <button
onclick={onSyncParticipants} onclick={handleSyncParticipants}
disabled={syncingParticipants || !event || loading} 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" class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
> >

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"> <script lang="ts">
import type { User } from '@supabase/supabase-js'; let { data } = $props();
export let data: {
user: User | null,
user_profile: any | null
};
</script> </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="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="flex flex-col gap-2"> <!-- Left Column: User Profile -->
<div class="flex items-center gap-3 mb-4"> <div class="lg:col-span-1">
<div class="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-600"> <div class="flex h-full flex-col rounded-lg border border-gray-300 bg-white p-6">
{data.user?.user_metadata.display_name?.[0] ?? "U"} <div class="flex flex-grow flex-col items-center text-center">
</div> <div
<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"
<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> {data.profile?.display_name?.[0]?.toUpperCase() ?? 'U'}
</div> </div>
</div> <h2 class="text-xl font-semibold text-gray-900">{data.profile?.display_name}</h2>
<div class="flex flex-col gap-1"> <p class="text-sm text-gray-500">{data.user?.email}</p>
<div> </div>
<span class="font-medium text-gray-700">Section:</span> <div class="mt-6 text-center">
<span class="text-gray-900">{data.user_profile?.section.name ?? "N/A"}</span> <a
</div> href="/auth/signout"
<div> class="text-sm text-red-500 transition hover:text-red-700 hover:underline">Sign Out</a
<span class="font-medium text-gray-700">Position:</span> >
<span class="text-gray-900">{data.user_profile?.section_position ?? "N/A"}</span> </div>
</div> </div>
</div> </div>
<h2 class="text-lg mb-2 mt-4">User guide</h2>
<p class="text-gray-700 text-sm leading-relaxed"> <!-- Right Column: Information -->
To scan a QR code, head over to Scanner in the top right corner. Click on Start scanning and allow camera permissions. <div class="space-y-6 lg:col-span-2">
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. <!-- Role Information -->
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. <div class="rounded-lg border border-gray-300 bg-white p-6">
</p> <h2 class="mb-4 text-lg font-semibold text-gray-900">Your Role</h2>
<h2 class="text-lg mb-2 mt-4">Administrator guide</h2> <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<p class="text-gray-700 text-sm leading-relaxed"> <div class="sm:col-span-1">
You can view events <dt class="text-sm font-medium text-gray-500">Section</dt>
</p> <dd class="mt-1 text-sm font-semibold text-gray-900">
</div> {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> </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

@@ -24,6 +24,11 @@
let eventsError = $state(''); let eventsError = $state('');
onMount(async () => { onMount(async () => {
// Load the persisted event ID from local storage
const storedEventId = localStorage.getItem('selectedScannerEventId');
if (storedEventId) {
selectedEventId = storedEventId;
}
await loadEvents(); await loadEvents();
}); });
@@ -40,9 +45,14 @@
if (error) throw error; if (error) throw error;
events = eventsData || []; events = eventsData || [];
// If there are events, select the first one by default // Check if the previously selected event is still in the list
if (events.length > 0) { 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; selectedEventId = events[0].id;
} else if (events.length === 0) {
selectedEventId = ''; // No events available
} }
} catch (err) { } catch (err) {
console.error('Error loading events:', err); console.error('Error loading events:', err);
@@ -52,6 +62,13 @@
} }
} }
// Persist the selected event ID to local storage whenever it changes
$effect(() => {
if (selectedEventId) {
localStorage.setItem('selectedScannerEventId', selectedEventId);
}
});
// Process a scanned QR code // Process a scanned QR code
$effect(() => { $effect(() => {
if (scanned_id === '') return; if (scanned_id === '') return;

View File

@@ -45,9 +45,9 @@
}); });
</script> </script>
<div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded-lg border border-gray-300"></div> <div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded"></div>
<style> <style lang="postcss">
/* Hide unwanted icons */ /* Hide unwanted icons */
#qr-scanner :global(img[alt='Info icon']), #qr-scanner :global(img[alt='Info icon']),
#qr-scanner :global(img[alt='Camera based scan']) { #qr-scanner :global(img[alt='Camera based scan']) {
@@ -58,21 +58,51 @@
color: black !important; 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) { #qr-scanner :global(#qr-scanner__scan_region) {
min-height: auto !important; min-height: auto !important;
aspect-ratio: 1 !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> </style>

View File

@@ -15,7 +15,7 @@
} }
</script> </script>
<div class="border border-gray-300 rounded-lg overflow-hidden h-[200px]"> <div class="border border-gray-300 rounded-lg overflow-hidden h-[200px] mb-4">
<div class="h-full flex flex-col"> <div class="h-full flex flex-col">
{#if scan_state === ScanState.scanning} {#if scan_state === ScanState.scanning}
<div class="bg-gray-50 p-4 flex-1 flex flex-col justify-center items-center"> <div class="bg-gray-50 p-4 flex-1 flex flex-col justify-center items-center">
@@ -47,7 +47,7 @@
</div> </div>
<p class="text-amber-700 mb-2">This ticket belongs to a different event:</p> <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"> <div class="bg-white rounded p-3 border border-amber-200 mt-auto">
<p class="font-medium">{ticket_data.event?.name || ''}</p> <p class="font-bold">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p> <p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div> </div>
</div> </div>
@@ -64,7 +64,7 @@
{ticket_data.scanned_at ? `on ${formatScannedAt(ticket_data.scanned_at)}` : ''} {ticket_data.scanned_at ? `on ${formatScannedAt(ticket_data.scanned_at)}` : ''}
</p> </p>
<div class="bg-white rounded p-3 border border-amber-200 mt-auto"> <div class="bg-white rounded p-3 border border-amber-200 mt-auto">
<p class="font-medium">{ticket_data.event?.name || ''}</p> <p class="font-bold">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p> <p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div> </div>
</div> </div>
@@ -78,7 +78,7 @@
</div> </div>
<p class="text-green-700">Ticket successfully validated.</p> <p class="text-green-700">Ticket successfully validated.</p>
<div class="bg-white rounded p-3 border border-green-200 mt-auto"> <div class="bg-white rounded p-3 border border-green-200 mt-auto">
<p class="font-medium">{ticket_data.event?.name || ''}</p> <p class="font-bold">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p> <p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,86 +1,71 @@
/// <reference lib="webworker" />
/// <reference types="@sveltejs/kit" /> /// <reference types="@sveltejs/kit" />
import { build, files, version } from '$service-worker'; import { build, files, version } from '$service-worker';
// Create a unique cache name for this deployment declare const self: ServiceWorkerGlobalScope;
const CACHE = `cache-${version}`;
const CACHE = `cache-${version}`;
const ASSETS = [ const ASSETS = [
...build, // the app itself ...build,
...files // everything in `static` ...files
]; ];
self.addEventListener('install', (event) => { self.addEventListener('install', (event: ExtendableEvent) => {
// Create a new cache and add all files to it const addFilesToCache = async () => {
async function addFilesToCache() { const cache = await caches.open(CACHE);
const cache = await caches.open(CACHE); await cache.addAll(ASSETS);
await cache.addAll(ASSETS); };
}
event.waitUntil(addFilesToCache()); console.log("[SW] Installing new service worker");
event.waitUntil(addFilesToCache());
self.skipWaiting();
}); });
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event: ExtendableEvent) => {
// Remove previous cached data from disk const deleteOldCaches = async () => {
async function deleteOldCaches() { for (const key of await caches.keys()) {
for (const key of await caches.keys()) { if (key !== CACHE) await caches.delete(key);
if (key !== CACHE) await caches.delete(key); console.log("[SW] Removing old service worker")
} }
} };
event.waitUntil(deleteOldCaches()); event.waitUntil(deleteOldCaches());
self.clients.claim();
}); });
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event: FetchEvent) => {
// ignore POST requests etc if (event.request.method !== 'GET') return;
if (event.request.method !== 'GET') return;
async function respond() { const url = new URL(event.request.url);
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); // Never cache private routes
if (url.pathname.startsWith('/private')) {
event.respondWith(fetch(event.request));
return;
}
// `build`/`files` can always be served from the cache const respond = async () => {
if (ASSETS.includes(url.pathname)) { const cache = await caches.open(CACHE);
const response = await cache.match(url.pathname);
if (response) { if (ASSETS.includes(url.pathname)) {
return response; const cached = await cache.match(url.pathname);
} if (cached) return cached;
} }
// for everything else, try the network first, but try {
// fall back to the cache if we're offline const response = await fetch(event.request);
try { if (response.status === 200 && build.length > 0 && url.pathname.startsWith(`/${build[0]}/`)) {
const response = await fetch(event.request); cache.put(event.request, response.clone());
}
return response;
} catch {
const cached = await cache.match(event.request);
if (cached) return cached;
}
// if we're offline, fetch can return a value that is not a Response return new Response('Not found', { status: 404 });
// 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) { event.respondWith(respond());
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.