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
This commit is contained in:
2025-07-14 21:40:16 +02:00
25 changed files with 974 additions and 246 deletions

View File

@@ -9,6 +9,12 @@ Basics: These you need to really follow!
- Never use supabse-js. I am using supabse-ssr and supabase client is located in: - Never use supabse-js. I am using supabse-ssr and supabase client is located in:
- client: $props.data.supabse - client: $props.data.supabse
- server: $locals.supabase - 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!

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

@@ -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,6 +83,11 @@ 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')
} }
@@ -75,6 +96,13 @@ const authGuard: Handle = async ({ event, resolve }) => {
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

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

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,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import ToastContainer from '$lib/components/ToastContainer.svelte';
let { data, children } = $props();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -16,19 +19,26 @@
<nav class="border-b border-gray-300 bg-gray-50 p-2 text-gray-900"> <nav class="border-b border-gray-300 bg-gray-50 p-2 text-gray-900">
<div class="container mx-auto max-w-2xl p-2"> <div class="container mx-auto max-w-2xl p-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-lg font-bold">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">Home</a></li>
<li><a href="/private/scanner">Scanner</a></li> <li><a href="/private/scanner">Scanner</a></li>
<li><a href="/private/events">Events</a></li> {#if data.profile?.section_position === 'events_manager'}
<li><a href="/private/events">Events</a></li>
{/if}
</ul> </ul>
</div> </div>
</div> </div>
</nav> </nav>
<QueryClientProvider client={queryClient}>
<div class="container mx-auto max-w-2xl bg-white p-2"> <div class="container mx-auto max-w-2xl bg-white p-2">
<slot /> <QueryClientProvider client={queryClient}>
</div> {@render children()}
</QueryClientProvider> </QueryClientProvider>
</div>
<ToastContainer />

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

@@ -3,7 +3,8 @@
import { isTokenValid, getUserInfo, revokeToken } from '$lib/google/auth/client.js'; 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';
@@ -41,7 +42,7 @@
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, // Initialize to 0 (no column selected)
surname: 0, surname: 0,
email: 0, email: 0,
confirmation: 0 confirmation: 0
@@ -63,7 +64,7 @@
onMount(async () => { onMount(async () => {
// Check Google auth status on mount // Check Google auth status on mount
await checkGoogleAuth(); await checkGoogleAuth();
if (currentStep === 2) { if (currentStep === 2) {
await loadRecentSheets(); await loadRecentSheets();
} }
@@ -75,13 +76,13 @@
try { try {
const accessToken = localStorage.getItem('google_access_token'); const accessToken = localStorage.getItem('google_access_token');
const refreshToken = localStorage.getItem('google_refresh_token'); const refreshToken = localStorage.getItem('google_refresh_token');
if (accessToken && refreshToken) { if (accessToken && refreshToken) {
// Check if token is still valid // Check if token is still valid
const isValid = await isTokenValid(accessToken); const isValid = await isTokenValid(accessToken);
authData.isConnected = isValid; authData.isConnected = isValid;
authData.token = accessToken; authData.token = accessToken;
if (isValid) { if (isValid) {
// Fetch user info // Fetch user info
await fetchUserInfo(accessToken); await fetchUserInfo(accessToken);
@@ -103,15 +104,16 @@
async function connectToGoogle() { async function connectToGoogle() {
authData.error = ''; authData.error = '';
authData.connecting = true; authData.connecting = true;
try { try {
// Open popup window for OAuth // Open popup window for OAuth
const popup = window.open( const popup = window.open(
'/auth/google', '/auth/google',
'google-auth', 'google-auth',
'width=500,height=600,scrollbars=yes,resizable=yes,left=' + 'width=500,height=600,scrollbars=yes,resizable=yes,left=' +
Math.round(window.screen.width / 2 - 250) + ',top=' + Math.round(window.screen.width / 2 - 250) +
Math.round(window.screen.height / 2 - 300) ',top=' +
Math.round(window.screen.height / 2 - 300)
); );
if (!popup) { if (!popup) {
@@ -126,12 +128,12 @@
// Store current timestamp to detect changes in localStorage // Store current timestamp to detect changes in localStorage
const startTimestamp = localStorage.getItem('google_auth_timestamp') || '0'; const startTimestamp = localStorage.getItem('google_auth_timestamp') || '0';
// Poll localStorage for auth completion // Poll localStorage for auth completion
const pollInterval = setInterval(() => { const pollInterval = setInterval(() => {
try { try {
const currentTimestamp = localStorage.getItem('google_auth_timestamp'); const currentTimestamp = localStorage.getItem('google_auth_timestamp');
// If timestamp has changed, auth is complete // If timestamp has changed, auth is complete
if (currentTimestamp && currentTimestamp !== startTimestamp) { if (currentTimestamp && currentTimestamp !== startTimestamp) {
handleAuthSuccess(); handleAuthSuccess();
@@ -140,24 +142,24 @@
console.error('Error checking auth timestamp:', e); console.error('Error checking auth timestamp:', e);
} }
}, 500); // Poll every 500ms }, 500); // Poll every 500ms
// Common handler for authentication success // Common handler for authentication success
function handleAuthSuccess() { function handleAuthSuccess() {
if (authCompleted) return; // Prevent duplicate handling if (authCompleted) return; // Prevent duplicate handling
authCompleted = true; authCompleted = true;
authData.connecting = false; authData.connecting = false;
authData.showCancelOption = false; authData.showCancelOption = false;
// Clean up timers // Clean up timers
clearInterval(pollInterval); clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer); if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout); if (cancelTimeout) clearTimeout(cancelTimeout);
// Update auth state // Update auth state
setTimeout(checkGoogleAuth, 100); setTimeout(checkGoogleAuth, 100);
} }
// Clean up function to handle all cleanup in one place // Clean up function to handle all cleanup in one place
const cleanUp = () => { const cleanUp = () => {
clearInterval(pollInterval); clearInterval(pollInterval);
@@ -189,19 +191,18 @@
cleanUp(); cleanUp();
} }
}, 60 * 1000); // Reduced from 3min to 1min }, 60 * 1000); // Reduced from 3min to 1min
} catch (error) { } catch (error) {
console.error('Error connecting to Google:', error); console.error('Error connecting to Google:', error);
authData.error = 'Failed to connect to Google'; authData.error = 'Failed to connect to Google';
authData.connecting = false; authData.connecting = false;
} }
} }
function cancelGoogleAuth() { function cancelGoogleAuth() {
authData.connecting = false; authData.connecting = false;
authData.showCancelOption = false; authData.showCancelOption = false;
} }
async function fetchUserInfo(accessToken: string) { async function fetchUserInfo(accessToken: string) {
try { try {
// Use the new getUserInfo function from our lib // Use the new getUserInfo function from our lib
@@ -216,7 +217,7 @@
authData.userEmail = null; authData.userEmail = null;
} }
} }
async function disconnectGoogle() { async function disconnectGoogle() {
try { try {
// First revoke the token at Google using our API // First revoke the token at Google using our API
@@ -224,16 +225,16 @@
if (accessToken) { if (accessToken) {
await revokeToken(accessToken); await revokeToken(accessToken);
} }
// Remove tokens from local storage // Remove tokens from local storage
localStorage.removeItem('google_access_token'); localStorage.removeItem('google_access_token');
localStorage.removeItem('google_refresh_token'); localStorage.removeItem('google_refresh_token');
// Update auth state // Update auth state
authData.isConnected = false; authData.isConnected = false;
authData.token = null; authData.token = null;
authData.userEmail = null; authData.userEmail = null;
// Clear any selected sheets data // Clear any selected sheets data
sheetsData.availableSheets = []; sheetsData.availableSheets = [];
sheetsData.selectedSheet = null; sheetsData.selectedSheet = null;
@@ -259,49 +260,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 (!authData.isConnected) {
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 +326,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 +349,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
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, { const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, {
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 +378,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 +417,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;
} }
@@ -410,49 +448,46 @@
}); });
</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={(token) => {
onSuccess={(token) => { authData.error = null;
authData.error = null; authData.token = token;
authData.token = token; authData.isConnected = true;
authData.isConnected = true; setTimeout(checkGoogleAuth, 100);
setTimeout(checkGoogleAuth, 100); }}
}} onError={(error) => {
onError={(error) => { authData.error = error;
authData.error = error; authData.isConnected = false;
authData.isConnected = false; }}
}} />
/> {:else if currentStep === 1}
{:else if currentStep === 1} <EventDetailsStep bind:eventData />
<EventDetailsStep bind:eventData bind:errors /> {:else if currentStep === 2}
{:else if currentStep === 2} <GoogleSheetsStep
<GoogleSheetsStep bind:sheetsData bind:errors {loadRecentSheets} {selectSheet} {toggleSheetList} /> bind:sheetsData
{:else if currentStep === 3} {loadRecentSheets}
<EmailSettingsStep bind:emailData bind:errors /> {selectSheet}
{/if} {toggleSheetList}
{resetSheetSelection}
{#if errors.submit} />
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded"> {:else if currentStep === 3}
<p class="text-sm text-red-600">{errors.submit}</p> <EmailSettingsStep bind:emailData />
</div> {/if}
{/if}
</div>
<!-- Navigation -->
<StepNavigation
{currentStep}
{totalSteps}
{canProceed}
{loading}
{prevStep}
{nextStep}
{createEvent}
/>
</div> </div>
<!-- Navigation -->
<StepNavigation
{currentStep}
{totalSteps}
{canProceed}
{loading}
{prevStep}
{nextStep}
{createEvent}
/>

View File

@@ -1,10 +1,9 @@
<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 = [ const templateVariables = [
@@ -37,9 +36,6 @@
Detected templates: {subjectTemplatesDetected.map((v) => v.name).join(', ')} Detected templates: {subjectTemplatesDetected.map((v) => v.name).join(', ')}
</p> </p>
{/if} {/if}
{#if errors.subject}
<p class="text-sm text-red-600">{errors.subject}</p>
{/if}
</div> </div>
<div> <div>
@@ -58,9 +54,6 @@
Detected templates: {bodyTemplatesDetected.map((v) => v.name).join(', ')} Detected templates: {bodyTemplatesDetected.map((v) => v.name).join(', ')}
</p> </p>
{/if} {/if}
{#if errors.body}
<p class="mt-1 text-sm text-red-600">{errors.body}</p>
{/if}
</div> </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,8 +2,7 @@
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte'; import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props // Props
let { errors, onSuccess, onError } = $props<{ let { onSuccess, onError } = $props<{
errors: Record<string, string>;
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
}>(); }>();
@@ -22,11 +21,5 @@
onSuccess={onSuccess} onSuccess={onSuccess}
onError={onError} onError={onError}
/> />
{#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,26 @@
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 = '';
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`, {
@@ -175,9 +177,19 @@
// Reload participants // Reload participants
await loadParticipants(); await loadParticipants();
// Show success message with count of synced participants
const previousCount = participants.length;
const newCount = names.length;
const addedCount = Math.max(0, participants.length - previousCount);
toast.success(
`Successfully synced participants. ${newCount} entries processed, ${addedCount} new participants added.`,
5000
);
} catch (err) { } 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 +201,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 +252,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 +349,12 @@ onSyncParticipants={syncParticipants}
/> />
</div> </div>
<EmailTemplate {event} {loading} /> <EmailTemplate
{event}
{loading}
{updatingEmail}
onUpdateEmail={handleEmailUpdate}
/>
<EmailSending <EmailSending
{loading} {loading}
@@ -303,7 +368,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 bg-gray-300 px-4 py-2 text-gray-700 transition hover:bg-gray-400 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 px-3 py-2 border border-gray-300 rounded-lg {!isEditing ? 'bg-gray-50 cursor-default' : ''} focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50"
/>
</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 px-3 py-2 border border-gray-300 rounded-lg {!isEditing ? 'bg-gray-50 cursor-default' : ''} focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50"
></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,6 +30,16 @@
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...', 2000);
// 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">

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

@@ -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,76 @@
<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> <div class="p-4 sm:p-6">
<h1 class="mb-6 text-2xl font-bold text-gray-800">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"
<span class="font-medium text-gray-700">Position:</span> >Sign Out</a
<span class="text-gray-900">{data.user_profile?.section_position ?? "N/A"}</span> >
</div> </div>
</div> </div>
<h2 class="text-lg mb-2 mt-4">User guide</h2> </div>
<p class="text-gray-700 text-sm leading-relaxed">
To scan a QR code, head over to Scanner in the top right corner. Click on Start scanning and allow camera permissions. <!-- Right Column: Information -->
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. <div class="space-y-6 lg:col-span-2">
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. <!-- Role Information -->
</p> <div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="text-lg mb-2 mt-4">Administrator guide</h2> <h2 class="mb-4 text-lg font-semibold text-gray-900">Your Role</h2>
<p class="text-gray-700 text-sm leading-relaxed"> <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
You can view events <div class="sm:col-span-1">
</p> <dt class="text-sm font-medium text-gray-500">Section</dt>
</div> <dd class="mt-1 text-sm font-semibold text-gray-900">
{data.profile?.section?.name ?? 'N/A'}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Position</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">
{data.profile?.section_position ?? 'N/A'}
</dd>
</div>
</dl>
</div>
<!-- User Guide -->
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-2 text-lg font-semibold text-gray-900">User Guide</h2>
<p class="text-sm leading-relaxed text-gray-700">
To scan a QR code, head over to <strong>Scanner</strong> in the top right corner. Click
on "Start Scanning" and allow camera permissions. If your camera gets stuck, simply
refresh the page or click "Stop Scanning" and then "Start Scanning" again. When you scan
a QR code, the participant's ticket is automatically marked as scanned.
</p>
</div>
<!-- Events Manager Guide -->
{#if data.profile?.section_position === 'events_manager'}
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-2 text-lg font-semibold text-gray-900">Events Manager Guide</h2>
<p class="text-sm leading-relaxed text-gray-700">
As an Events Manager, you have access to the <strong>Events</strong> section. Here you
can create new events, manage participants by syncing with Google Sheets, send email
invitations with QR codes, and view event statistics.
</p>
</div>
{/if}
</div>
</div>
</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>