Compare commits
3 Commits
a8f1b973e6
...
6466665549
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6466665549 | ||
|
|
b9db3d22a3 | ||
|
|
06f2553b42 |
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -9,6 +9,8 @@ Basics: These you need to really follow!
|
||||
- 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.
|
||||
|
||||
Do not fall back to the legacy $: label syntax or Svelte 3/4 stores! This is important!
|
||||
|
||||
|
||||
25
src/lib/components/ToastContainer.svelte
Normal file
25
src/lib/components/ToastContainer.svelte
Normal 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>
|
||||
115
src/lib/components/ToastNotification.svelte
Normal file
115
src/lib/components/ToastNotification.svelte
Normal 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
63
src/lib/stores/toast.ts
Normal 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();
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -27,8 +28,14 @@
|
||||
</div>
|
||||
</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">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<slot />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { isTokenValid, getUserInfo, revokeToken } from '$lib/google/auth/client.js';
|
||||
import type { GoogleSheet } from '$lib/google/sheets/types.ts';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
|
||||
// Import Components
|
||||
import GoogleAuthStep from './components/GoogleAuthStep.svelte';
|
||||
import EventDetailsStep from './components/EventDetailsStep.svelte';
|
||||
@@ -41,7 +42,7 @@
|
||||
selectedSheet: null as GoogleSheet | null,
|
||||
sheetData: [] as string[][],
|
||||
columnMapping: {
|
||||
name: 0, // Initialize to 0 (no column selected)
|
||||
name: 0, // Initialize to 0 (no column selected)
|
||||
surname: 0,
|
||||
email: 0,
|
||||
confirmation: 0
|
||||
@@ -63,7 +64,7 @@
|
||||
onMount(async () => {
|
||||
// Check Google auth status on mount
|
||||
await checkGoogleAuth();
|
||||
|
||||
|
||||
if (currentStep === 2) {
|
||||
await loadRecentSheets();
|
||||
}
|
||||
@@ -75,13 +76,13 @@
|
||||
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);
|
||||
@@ -103,15 +104,16 @@
|
||||
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)
|
||||
'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) {
|
||||
@@ -126,12 +128,12 @@
|
||||
|
||||
// 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();
|
||||
@@ -140,24 +142,24 @@
|
||||
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);
|
||||
@@ -189,19 +191,18 @@
|
||||
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
|
||||
@@ -216,7 +217,7 @@
|
||||
authData.userEmail = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function disconnectGoogle() {
|
||||
try {
|
||||
// First revoke the token at Google using our API
|
||||
@@ -224,16 +225,16 @@
|
||||
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;
|
||||
@@ -259,49 +260,65 @@
|
||||
}
|
||||
|
||||
function validateCurrentStep(): boolean {
|
||||
// Clear previous errors
|
||||
errors = {};
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (currentStep === 0) {
|
||||
if (!authData.isConnected) {
|
||||
toast.error('Please connect your Google account to continue');
|
||||
errors.auth = 'Please connect your Google account to continue';
|
||||
return false;
|
||||
}
|
||||
} else if (currentStep === 1) {
|
||||
if (!eventData.name.trim()) {
|
||||
toast.error('Event name is required');
|
||||
errors.name = 'Event name is required';
|
||||
isValid = false;
|
||||
}
|
||||
if (!eventData.date) {
|
||||
toast.error('Event date is required');
|
||||
errors.date = 'Event date is required';
|
||||
isValid = false;
|
||||
}
|
||||
} else if (currentStep === 2) {
|
||||
if (!sheetsData.selectedSheet) {
|
||||
toast.error('Please select a Google Sheet');
|
||||
errors.sheet = 'Please select a Google Sheet';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
|
||||
if (sheetsData.selectedSheet) {
|
||||
// Validate column mappings
|
||||
const { name, surname, email, confirmation } = sheetsData.columnMapping;
|
||||
const missingColumns = [];
|
||||
|
||||
|
||||
if (!name) missingColumns.push('Name');
|
||||
if (!surname) missingColumns.push('Surname');
|
||||
if (!email) missingColumns.push('Email');
|
||||
if (!confirmation) missingColumns.push('Confirmation');
|
||||
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
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) {
|
||||
if (!emailData.subject.trim()) {
|
||||
toast.error('Email subject is required');
|
||||
errors.subject = 'Email subject is required';
|
||||
isValid = false;
|
||||
}
|
||||
if (!emailData.body.trim()) {
|
||||
toast.error('Email body is required');
|
||||
errors.body = 'Email body is required';
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Google Sheets functions
|
||||
@@ -309,16 +326,16 @@
|
||||
sheetsData.loading = true;
|
||||
// Always expand the sheet list when loading new sheets
|
||||
sheetsData.expandedSheetList = true;
|
||||
|
||||
|
||||
try {
|
||||
// Use the new unified API endpoint
|
||||
const response = await fetch('/private/api/google/sheets/recent', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}`
|
||||
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
sheetsData.availableSheets = await response.json();
|
||||
}
|
||||
@@ -332,24 +349,24 @@
|
||||
|
||||
async function selectSheet(sheet: GoogleSheet) {
|
||||
const sameSheet = sheetsData.selectedSheet?.id === sheet.id;
|
||||
|
||||
|
||||
sheetsData.selectedSheet = sheet;
|
||||
sheetsData.loading = true;
|
||||
|
||||
|
||||
// Collapse sheet list when selecting a new sheet
|
||||
if (!sameSheet) {
|
||||
sheetsData.expandedSheetList = false;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Use the new unified API endpoint
|
||||
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}`
|
||||
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
sheetsData.sheetData = data.values || [];
|
||||
@@ -361,19 +378,32 @@
|
||||
sheetsData.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Toggle the sheet list expansion
|
||||
function toggleSheetList() {
|
||||
sheetsData.expandedSheetList = !sheetsData.expandedSheetList;
|
||||
}
|
||||
|
||||
// Reset sheet selection and show sheet list
|
||||
function resetSheetSelection() {
|
||||
sheetsData.selectedSheet = null;
|
||||
sheetsData.sheetData = [];
|
||||
sheetsData.columnMapping = {
|
||||
name: 0,
|
||||
surname: 0,
|
||||
email: 0,
|
||||
confirmation: 0
|
||||
};
|
||||
sheetsData.expandedSheetList = true;
|
||||
}
|
||||
|
||||
// Final submission
|
||||
async function createEvent() {
|
||||
if (!validateCurrentStep()) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const { error } = await data.supabase.rpc('create_event', {
|
||||
const { data: newEvent, error } = await data.supabase.rpc('create_event', {
|
||||
p_name: eventData.name,
|
||||
p_date: eventData.date,
|
||||
p_email_subject: emailData.subject,
|
||||
@@ -387,11 +417,19 @@
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Redirect to events list or show success message
|
||||
goto('/private/events');
|
||||
// Display success message
|
||||
toast.success(`Event "${eventData.name}" was created successfully`);
|
||||
|
||||
// Redirect to the event view page using the returned event ID
|
||||
if (newEvent) {
|
||||
goto(`/private/events/event/view?id=${newEvent.id}`);
|
||||
} else {
|
||||
// Fallback to events list if for some reason the event ID wasn't returned
|
||||
goto('/private/events');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error);
|
||||
errors.submit = 'Failed to create event. Please try again.';
|
||||
toast.error('Failed to create event. Please try again.');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -410,49 +448,46 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto p-6">
|
||||
<!-- Header -->
|
||||
<StepNavigator {currentStep} {totalSteps} />
|
||||
<!-- Header -->
|
||||
<StepNavigator {currentStep} {totalSteps} />
|
||||
|
||||
<!-- Step Content -->
|
||||
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
|
||||
{#if currentStep === 0}
|
||||
<GoogleAuthStep
|
||||
bind:errors
|
||||
onSuccess={(token) => {
|
||||
authData.error = null;
|
||||
authData.token = token;
|
||||
authData.isConnected = true;
|
||||
setTimeout(checkGoogleAuth, 100);
|
||||
}}
|
||||
onError={(error) => {
|
||||
authData.error = error;
|
||||
authData.isConnected = false;
|
||||
}}
|
||||
/>
|
||||
{:else if currentStep === 1}
|
||||
<EventDetailsStep bind:eventData bind:errors />
|
||||
{:else if currentStep === 2}
|
||||
<GoogleSheetsStep bind:sheetsData bind:errors {loadRecentSheets} {selectSheet} {toggleSheetList} />
|
||||
{:else if currentStep === 3}
|
||||
<EmailSettingsStep bind:emailData bind:errors />
|
||||
{/if}
|
||||
|
||||
{#if errors.submit}
|
||||
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded">
|
||||
<p class="text-sm text-red-600">{errors.submit}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<StepNavigation
|
||||
{currentStep}
|
||||
{totalSteps}
|
||||
{canProceed}
|
||||
{loading}
|
||||
{prevStep}
|
||||
{nextStep}
|
||||
{createEvent}
|
||||
/>
|
||||
<!-- Step Content -->
|
||||
<div class="mb-4 rounded border border-gray-300 bg-white p-6">
|
||||
{#if currentStep === 0}
|
||||
<GoogleAuthStep
|
||||
onSuccess={(token) => {
|
||||
authData.error = null;
|
||||
authData.token = token;
|
||||
authData.isConnected = true;
|
||||
setTimeout(checkGoogleAuth, 100);
|
||||
}}
|
||||
onError={(error) => {
|
||||
authData.error = error;
|
||||
authData.isConnected = false;
|
||||
}}
|
||||
/>
|
||||
{:else if currentStep === 1}
|
||||
<EventDetailsStep bind:eventData />
|
||||
{:else if currentStep === 2}
|
||||
<GoogleSheetsStep
|
||||
bind:sheetsData
|
||||
{loadRecentSheets}
|
||||
{selectSheet}
|
||||
{toggleSheetList}
|
||||
{resetSheetSelection}
|
||||
/>
|
||||
{:else if currentStep === 3}
|
||||
<EmailSettingsStep bind:emailData />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<StepNavigation
|
||||
{currentStep}
|
||||
{totalSteps}
|
||||
{canProceed}
|
||||
{loading}
|
||||
{prevStep}
|
||||
{nextStep}
|
||||
{createEvent}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
let { emailData = $bindable(), errors = $bindable() } = $props<{
|
||||
let { emailData = $bindable() } = $props<{
|
||||
emailData: {
|
||||
subject: string;
|
||||
body: string;
|
||||
};
|
||||
errors: Record<string, string>;
|
||||
}>();
|
||||
|
||||
const templateVariables = [
|
||||
@@ -37,9 +36,6 @@
|
||||
Detected templates: {subjectTemplatesDetected.map((v) => v.name).join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if errors.subject}
|
||||
<p class="text-sm text-red-600">{errors.subject}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -58,9 +54,6 @@
|
||||
Detected templates: {bodyTemplatesDetected.map((v) => v.name).join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if errors.body}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.body}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
let { eventData = $bindable(), errors = $bindable() } = $props<{
|
||||
let { eventData = $bindable() } = $props<{
|
||||
eventData: {
|
||||
name: string;
|
||||
date: string;
|
||||
};
|
||||
errors: Record<string, string>;
|
||||
}>();
|
||||
</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"
|
||||
placeholder="Enter event name"
|
||||
/>
|
||||
{#if errors.name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -37,8 +33,5 @@
|
||||
bind:value={eventData.date}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{#if errors.date}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.date}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
|
||||
|
||||
// Props
|
||||
let { errors, onSuccess, onError } = $props<{
|
||||
errors: Record<string, string>;
|
||||
let { onSuccess, onError } = $props<{
|
||||
onSuccess?: (token: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
}>();
|
||||
@@ -22,11 +21,5 @@
|
||||
onSuccess={onSuccess}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
{#if errors.google}
|
||||
<div class="mt-4 text-sm text-red-600">
|
||||
{errors.google}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { GoogleSheet } from '$lib/google/sheets/types.ts';
|
||||
|
||||
// Props
|
||||
let { sheetsData = $bindable(), errors = $bindable(), loadRecentSheets, selectSheet, toggleSheetList } = $props<{
|
||||
let { sheetsData = $bindable(), loadRecentSheets, selectSheet, toggleSheetList, resetSheetSelection } = $props<{
|
||||
sheetsData: {
|
||||
availableSheets: GoogleSheet[];
|
||||
selectedSheet: GoogleSheet | null;
|
||||
@@ -16,10 +16,10 @@
|
||||
loading: boolean;
|
||||
expandedSheetList: boolean;
|
||||
};
|
||||
errors: Record<string, string>;
|
||||
loadRecentSheets: () => Promise<void>;
|
||||
selectSheet: (sheet: GoogleSheet) => Promise<void>;
|
||||
toggleSheetList: () => void;
|
||||
resetSheetSelection: () => void;
|
||||
}>();
|
||||
|
||||
// Search functionality
|
||||
@@ -124,13 +124,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={toggleSheetList}
|
||||
onclick={resetSheetSelection}
|
||||
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>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -256,10 +256,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if errors.sheet}
|
||||
<p class="mt-2 text-sm text-red-600">{errors.sheet}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0}
|
||||
@@ -373,10 +369,6 @@
|
||||
<div class="text-gray-600">Loading sheet data...</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if errors.sheetData}
|
||||
<p class="text-sm text-red-600">{errors.sheetData}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="mb-8 mt-6">
|
||||
<div class="flex items-center justify-center gap-4 w-full">
|
||||
{#each Array(totalSteps) as _, index}
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
import ParticipantsTable from './components/ParticipantsTable.svelte';
|
||||
import EmailSending from './components/EmailSending.svelte';
|
||||
import EmailResults from './components/EmailResults.svelte';
|
||||
import ErrorMessage from './components/ErrorMessage.svelte';
|
||||
import Statistics from './components/Statistics.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -49,7 +50,6 @@
|
||||
let sendingEmails = $state(false);
|
||||
let emailProgress = $state({ sent: 0, total: 0 });
|
||||
let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null);
|
||||
let error = $state('');
|
||||
|
||||
// Get event ID from URL params
|
||||
let eventId = $derived(page.url.searchParams.get('id'));
|
||||
@@ -74,7 +74,10 @@
|
||||
event = eventData;
|
||||
} catch (err) {
|
||||
console.error('Error loading event:', err);
|
||||
error = 'Failed to load event';
|
||||
toast.add({
|
||||
message: 'Failed to load event',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -95,7 +98,10 @@
|
||||
participants = participantsData || [];
|
||||
} catch (err) {
|
||||
console.error('Error loading participants:', err);
|
||||
error = 'Failed to load participants';
|
||||
toast.add({
|
||||
message: 'Failed to load participants',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
participantsLoading = false;
|
||||
}
|
||||
@@ -107,12 +113,14 @@
|
||||
// Check if user has Google authentication
|
||||
const refreshToken = localStorage.getItem('google_refresh_token');
|
||||
if (!refreshToken) {
|
||||
error = 'Please connect your Google account first to sync participants';
|
||||
toast.add({
|
||||
message: 'Please connect your Google account first to sync participants',
|
||||
type: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
syncingParticipants = true;
|
||||
error = '';
|
||||
try {
|
||||
// Fetch sheet data
|
||||
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
|
||||
@@ -177,7 +185,10 @@
|
||||
await loadParticipants();
|
||||
} catch (err) {
|
||||
console.error('Error syncing participants:', err);
|
||||
error = 'Failed to sync participants';
|
||||
toast.add({
|
||||
message: 'Failed to sync participants',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
syncingParticipants = false;
|
||||
}
|
||||
@@ -189,20 +200,25 @@
|
||||
// Check if user has Google authentication
|
||||
const refreshToken = localStorage.getItem('google_refresh_token');
|
||||
if (!refreshToken) {
|
||||
error = 'Please connect your Google account first to send emails';
|
||||
toast.add({
|
||||
message: 'Please connect your Google account first to send emails',
|
||||
type: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const uncontactedParticipants = participants.filter(p => !p.email_sent);
|
||||
if (uncontactedParticipants.length === 0) {
|
||||
error = 'No uncontacted participants found';
|
||||
toast.add({
|
||||
message: 'No uncontacted participants found',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendingEmails = true;
|
||||
emailProgress = { sent: 0, total: uncontactedParticipants.length };
|
||||
emailResults = null;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
// Send all emails in batch
|
||||
@@ -235,33 +251,40 @@
|
||||
});
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
} catch (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 {
|
||||
sendingEmails = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleGoogleAuthSuccess() {
|
||||
error = '';
|
||||
// Success handled by toast in the component
|
||||
}
|
||||
|
||||
function handleGoogleAuthError(errorMsg: string) {
|
||||
error = errorMsg;
|
||||
toast.add({
|
||||
message: errorMsg,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mt-2 mb-4">
|
||||
<h1 class="text-center text-2xl font-bold">Event Overview</h1>
|
||||
</div>
|
||||
|
||||
<!-- Composable components -->
|
||||
<EventInformation {event} {loading} {error} />
|
||||
<EventInformation {event} {loading} />
|
||||
|
||||
<GoogleAuthentication
|
||||
{loading}
|
||||
@@ -303,7 +326,3 @@ onSyncParticipants={syncParticipants}
|
||||
{#if emailResults}
|
||||
<EmailResults {emailResults} />
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<ErrorMessage {error} />
|
||||
{/if}
|
||||
|
||||
@@ -1,11 +1,127 @@
|
||||
<script lang="ts">
|
||||
let { error } = $props<{
|
||||
error: string;
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
message,
|
||||
type = 'error',
|
||||
duration = 50000,
|
||||
onDismiss
|
||||
} = $props<{
|
||||
message: string;
|
||||
type?: 'error' | 'success' | 'warning' | 'info';
|
||||
duration?: number;
|
||||
onDismiss?: () => void;
|
||||
}>();
|
||||
|
||||
let visible = $state(true);
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
// Auto-dismiss after specified duration
|
||||
onMount(() => {
|
||||
if (duration > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
dismiss();
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Cleanup timeout on component destroy
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function dismiss() {
|
||||
visible = false;
|
||||
if (onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
// Get styles based on toast type
|
||||
const getToastStyles = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'border-green-200 bg-green-50 text-green-800';
|
||||
case 'warning':
|
||||
return 'border-yellow-200 bg-yellow-50 text-yellow-800';
|
||||
case 'info':
|
||||
return 'border-blue-200 bg-blue-50 text-blue-800';
|
||||
case 'error':
|
||||
default:
|
||||
return 'border-red-200 bg-red-50 text-red-800';
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon based on toast type
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✓';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
case 'info':
|
||||
return 'ℹ';
|
||||
case 'error':
|
||||
default:
|
||||
return '✕';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4 rounded border border-red-200 bg-red-50 p-3">
|
||||
<p class="text-sm text-red-600">{error}</p>
|
||||
{#if visible && message}
|
||||
<div
|
||||
class="fixed top-4 left-4 z-50 max-w-sm rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out {getToastStyles(type)}"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-lg font-semibold" aria-hidden="true">
|
||||
{getIcon(type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="flex-shrink-0 ml-2 text-current opacity-70 hover:opacity-100 transition-opacity"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar for auto-dismiss -->
|
||||
{#if duration > 0}
|
||||
<div class="mt-2 h-1 w-full bg-black bg-opacity-10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-current opacity-30 transition-all ease-linear"
|
||||
style="animation: progress {duration}ms linear forwards;"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes progress {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
sheet_id: string;
|
||||
}
|
||||
|
||||
let { event, loading, error } = $props<{
|
||||
let { event, loading } = $props<{
|
||||
event: Event | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}>();
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
@@ -80,9 +79,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
{:else}
|
||||
<div class="py-8 text-center">
|
||||
<p class="text-red-600">{error}</p>
|
||||
<p class="text-gray-600">No event information available</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user