Better error norifications
This commit is contained in:
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:
|
- 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.
|
||||||
|
|
||||||
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!
|
||||||
|
|
||||||
|
|||||||
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">
|
<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';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -27,8 +28,14 @@
|
|||||||
</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">
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</QueryClientProvider>
|
||||||
</QueryClientProvider>
|
</div>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
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';
|
||||||
@@ -259,23 +260,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -289,19 +299,26 @@
|
|||||||
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
|
||||||
@@ -431,7 +448,7 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{:else if currentStep === 1}
|
{:else if currentStep === 1}
|
||||||
<EventDetailsStep bind:eventData bind:errors />
|
<EventDetailsStep bind:eventData />
|
||||||
{:else if currentStep === 2}
|
{:else if currentStep === 2}
|
||||||
<GoogleSheetsStep bind:sheetsData bind:errors {loadRecentSheets} {selectSheet} {toggleSheetList} />
|
<GoogleSheetsStep bind:sheetsData bind:errors {loadRecentSheets} {selectSheet} {toggleSheetList} />
|
||||||
{:else if currentStep === 3}
|
{:else if currentStep === 3}
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
Detected templates: {bodyTemplatesDetected.map((v) => v.name).join(', ')}
|
Detected templates: {bodyTemplatesDetected.map((v) => v.name).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if errors.body}
|
<!-- Errors now shown as toast notifications -->
|
||||||
<p class="mt-1 text-sm text-red-600">{errors.body}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}>();
|
}>();
|
||||||
@@ -23,10 +22,6 @@
|
|||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if errors.google}
|
<!-- Error messages are now shown as toast notifications -->
|
||||||
<div class="mt-4 text-sm text-red-600">
|
|
||||||
{errors.google}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 } = $props<{
|
||||||
sheetsData: {
|
sheetsData: {
|
||||||
availableSheets: GoogleSheet[];
|
availableSheets: GoogleSheet[];
|
||||||
selectedSheet: GoogleSheet | null;
|
selectedSheet: GoogleSheet | null;
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
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;
|
||||||
@@ -257,9 +256,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if errors.sheet}
|
<!-- Error messages are now shown as toast notifications -->
|
||||||
<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}
|
||||||
@@ -374,9 +371,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if errors.sheetData}
|
<!-- Error messages are now shown as toast notifications -->
|
||||||
<p class="text-sm text-red-600">{errors.sheetData}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
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 ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||||
|
import { toast } from '$lib/stores/toast.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -49,7 +50,6 @@
|
|||||||
let sendingEmails = $state(false);
|
let sendingEmails = $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,10 @@
|
|||||||
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.add({
|
||||||
|
message: 'Failed to load event',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,10 @@
|
|||||||
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.add({
|
||||||
|
message: 'Failed to load participants',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
participantsLoading = false;
|
participantsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -107,12 +113,14 @@
|
|||||||
// 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.add({
|
||||||
|
message: 'Please connect your Google account first to sync participants',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
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`, {
|
||||||
@@ -177,7 +185,10 @@
|
|||||||
await loadParticipants();
|
await loadParticipants();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error syncing participants:', err);
|
console.error('Error syncing participants:', err);
|
||||||
error = 'Failed to sync participants';
|
toast.add({
|
||||||
|
message: 'Failed to sync participants',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
syncingParticipants = false;
|
syncingParticipants = false;
|
||||||
}
|
}
|
||||||
@@ -189,20 +200,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 +251,40 @@
|
|||||||
});
|
});
|
||||||
} 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
||||||
@@ -303,7 +326,3 @@ onSyncParticipants={syncParticipants}
|
|||||||
{#if emailResults}
|
{#if emailResults}
|
||||||
<EmailResults {emailResults} />
|
<EmailResults {emailResults} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<ErrorMessage {error} />
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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