Compare commits
5 Commits
6466665549
...
ffbd3c7cda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffbd3c7cda | ||
|
|
5d957b18ee | ||
|
|
396d29c76b | ||
|
|
d0f555a7c5 | ||
|
|
f14213a5d4 |
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -11,6 +11,10 @@ Basics: These you need to really follow!
|
|||||||
- 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 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.
|
- 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
13
src/app.d.ts
vendored
@@ -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 {}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||||
|
|
||||||
|
let { data, children } = $props();
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
@@ -17,12 +19,13 @@
|
|||||||
<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>
|
||||||
@@ -31,7 +34,7 @@
|
|||||||
|
|
||||||
<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}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<slot />
|
{@render children()}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
50
src/routes/private/errors/events/denied/+page.svelte
Normal file
50
src/routes/private/errors/events/denied/+page.svelte
Normal 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>
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
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 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';
|
import { toast } from '$lib/stores/toast.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -48,6 +47,7 @@
|
|||||||
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);
|
||||||
|
|
||||||
@@ -74,10 +74,7 @@
|
|||||||
event = eventData;
|
event = eventData;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading event:', err);
|
console.error('Error loading event:', err);
|
||||||
toast.add({
|
toast.error('Failed to load event');
|
||||||
message: 'Failed to load event',
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -98,25 +95,22 @@
|
|||||||
participants = participantsData || [];
|
participants = participantsData || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading participants:', err);
|
console.error('Error loading participants:', err);
|
||||||
toast.add({
|
toast.error('Failed to load participants');
|
||||||
message: 'Failed to load participants',
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
} 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) {
|
||||||
toast.add({
|
toast.error('Please connect your Google account first to sync participants');
|
||||||
message: 'Please connect your Google account first to sync participants',
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,12 +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);
|
||||||
toast.add({
|
toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
message: 'Failed to sync participants',
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
syncingParticipants = false;
|
syncingParticipants = false;
|
||||||
}
|
}
|
||||||
@@ -268,6 +269,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
// Success handled by toast in the component
|
// Success handled by toast in the component
|
||||||
}
|
}
|
||||||
@@ -312,7 +349,12 @@ onSyncParticipants={syncParticipants}
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmailTemplate {event} {loading} />
|
<EmailTemplate
|
||||||
|
{event}
|
||||||
|
{loading}
|
||||||
|
{updatingEmail}
|
||||||
|
onUpdateEmail={handleEmailUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
<EmailSending
|
<EmailSending
|
||||||
{loading}
|
{loading}
|
||||||
|
|||||||
@@ -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">{name}</span>,
|
||||||
|
<span class="font-mono bg-gray-100 px-1 rounded">{surname}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Save button moved to the header -->
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user