Compare commits

40 Commits

Author SHA1 Message Date
e78902d79f Merge pull request 'development' (#25) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m14s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 28s
Reviewed-on: #25
2025-09-02 19:37:17 +02:00
Roman Krček
7b4a179428 Fix diff counting logic 2025-09-02 19:35:49 +02:00
Roman Krček
5ef9735ea5 Fixed hardcoded range 2025-09-02 19:30:33 +02:00
Roman Krček
03eeef5c69 Updated google button 2025-09-02 19:16:52 +02:00
Roman Krček
e52aea9dd3 Fixed private pages being cached
All checks were successful
Build Docker image / build (push) Successful in 1m22s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 1m8s
2025-09-02 18:43:49 +02:00
cdc8b89916 Merge pull request 'Added loading indicator' (#24) from development into main
All checks were successful
Build Docker image / build (push) Successful in 3m19s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 1m57s
Reviewed-on: #24
2025-09-02 18:26:28 +02:00
9dd79514f5 Merge branch 'main' into development 2025-09-02 18:21:55 +02:00
Roman Krček
fa685e6ba9 Added loading indicator 2025-09-02 18:21:18 +02:00
Roman Krček
cd37d8da0f Added loading indicator 2025-09-02 18:18:58 +02:00
eb0276e475 Dummy change to trigger actions 2025-09-02 18:00:47 +02:00
183854effd Merge pull request 'Add ability to search shared drives' (#22) from development into main
Reviewed-on: #22
2025-09-02 17:57:49 +02:00
Roman Krček
0fa20dffa5 Add ability to search shared drives 2025-09-02 17:56:31 +02:00
438f7299b4 Merge pull request 'CSS Styling' (#21) from development into main
Some checks failed
Build Docker image / build (push) Successful in 3m41s
Build Docker image / deploy (push) Has been cancelled
Build Docker image / verify (push) Has been cancelled
Reviewed-on: #21
2025-07-15 11:23:47 +02:00
Roman Krček
f4146e599b CSS Styling 2025-07-15 11:23:10 +02:00
dc6602a904 Merge pull request 'development' (#20) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m15s
Build Docker image / deploy (push) Successful in 8s
Build Docker image / verify (push) Successful in 37s
Reviewed-on: #20
2025-07-14 22:28:06 +02:00
Roman Krček
eb9fa14d28 Remember last event selected in scanner 2025-07-14 22:27:18 +02:00
Roman Krček
30f441a956 Styling and minor changes 2025-07-14 22:27:00 +02:00
Roman Krček
5b26b6951c Icons and better auth flow 2025-07-14 22:16:03 +02:00
fa2185a6a1 Merge pull request 'development' (#19) from development into main
All checks were successful
Build Docker image / build (push) Successful in 5m50s
Build Docker image / deploy (push) Successful in 7s
Build Docker image / verify (push) Successful in 1m22s
Reviewed-on: #19
2025-07-14 21:40:16 +02:00
Roman Krček
ffbd3c7cda Home styling 2025-07-14 21:39:49 +02:00
Roman Krček
5d957b18ee More notifications in participants table 2025-07-14 21:37:05 +02:00
Roman Krček
396d29c76b Make emails editable 2025-07-14 21:25:57 +02:00
Roman Krček
d0f555a7c5 Minor styling changes 2025-07-14 16:05:29 +02:00
Roman Krček
f14213a5d4 Add role base access control for events module 2025-07-14 15:50:07 +02:00
Roman Krček
6466665549 Redirect now directly to the event 2025-07-14 14:56:49 +02:00
Roman Krček
b9db3d22a3 Cleanup for error notifications 2025-07-14 14:34:38 +02:00
Roman Krček
06f2553b42 Better error norifications 2025-07-14 14:30:55 +02:00
b0e530ed62 Merge pull request 'development' (#18) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m40s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 25s
Reviewed-on: #18
2025-07-12 22:01:27 +02:00
Roman Krček
a8f1b973e6 Templating for names and surnames 2025-07-12 22:01:05 +02:00
Roman Krček
308e70941f Use tanstack for caching of events 2025-07-12 21:25:04 +02:00
Roman Krček
5a09b50e82 Get rid of leftover setup 2025-07-12 18:58:16 +02:00
c18a67e926 Merge pull request 'development' (#17) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m5s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 28s
Reviewed-on: #17
2025-07-12 15:44:07 +02:00
Roman Krček
a11bd416bf Add caching to service worker 2025-07-12 15:43:36 +02:00
Roman Krček
5751c6d6dc Fixed layout problems 2025-07-12 15:14:59 +02:00
45fa8b3005 Merge pull request 'supabase-local' (#16) from supabase-local into main
All checks were successful
Build Docker image / build (push) Successful in 1m12s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 25s
Reviewed-on: #16
2025-07-12 15:04:28 +02:00
c97acffe5b Merge pull request 'supabase-local' (#15) from supabase-local into main
All checks were successful
Build Docker image / build (push) Successful in 3m34s
Build Docker image / deploy (push) Successful in 7s
Build Docker image / verify (push) Successful in 42s
Reviewed-on: #15
2025-07-08 17:34:14 +02:00
99f2b778e5 Merge pull request 'development' (#14) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m47s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 28s
Reviewed-on: #14
2025-06-29 17:32:26 +02:00
0a556f144c Merge pull request 'development' (#13) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m9s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 26s
Reviewed-on: #13
2025-06-29 17:17:20 +02:00
9c99a88bb0 Merge pull request 'Fix build issues' (#12) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m54s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 24s
Reviewed-on: #12
2025-06-28 00:40:36 +02:00
15d2426ce6 Merge pull request 'Fix mixing old and new syntaxes error' (#11) from development into main
Some checks failed
Build Docker image / build (push) Failing after 40s
Build Docker image / deploy (push) Has been skipped
Build Docker image / verify (push) Has been skipped
Reviewed-on: #11
2025-06-28 00:36:08 +02:00
45 changed files with 1817 additions and 648 deletions

View File

@@ -1,11 +1,20 @@
GitHub Copilot Instructions for This Repository
Basics:
Basics: These you need to really follow!
- If you have any questions, always ask me first!
- Use Svelte 5 runes exclusively
- Declare reactive state with $state(); derive values with $derived(); run side-effect logic with $effect() etc.
- When doing client-side loading, always implement placeholders and loaders, so the UI remains responsive and layout shifts are minimized.
- Don't use placeholders and loaders for static data like heading etc.
- Never use supabse-js. I am using supabse-ssr and supabase client is located in:
- client: $props.data.supabse
- server: $locals.supabase
- Avoid unnceessary iterations. Once the problem is solved, ask me if i want to to continue and only then continue iterating.
- Avoid sweeping changes throught the project. If you want to change something globally, ask me first.
- to add a notification, use the toast component
- example: toast.success, toast.info, toast.warning, toast.error
Do not fall back to the legacy $: label syntax or Svelte 3/4 stores! This is important!

27
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12",
"@tanstack/svelte-query": "^5.83.0",
"googleapis": "^150.0.1",
"papaparse": "^5.5.3",
"qrcode": "^1.5.4",
@@ -1382,6 +1383,32 @@
"vite": "^5.2.0 || ^6"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz",
"integrity": "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/svelte-query": {
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-5.83.0.tgz",
"integrity": "sha512-8tNXhuoizntZXnAzo4yqUWgZZnklQkXGUNpb3YreW68DyCBhhrGbErnrODQs3fVc2ABcMvAHIki5uErbdzXH1A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.83.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"svelte": "^3.54.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",

View File

@@ -33,6 +33,7 @@
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12",
"@tanstack/svelte-query": "^5.83.0",
"googleapis": "^150.0.1",
"papaparse": "^5.5.3",
"qrcode": "^1.5.4",

13
src/app.d.ts vendored
View File

@@ -1,17 +1,30 @@
import type { Session, SupabaseClient, User } from '@supabase/supabase-js'
import type { 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 {
namespace App {
// interface Error {}
interface Locals {
supabase: SupabaseClient<Database>
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>
getUserProfile: (userId: string) => Promise<Profile | null>
session: Session | null
user: User | null
profile: Profile | null
}
interface PageData {
session: Session | null
user: User | null
profile: Profile | null
}
// interface PageState {}
// interface Platform {}

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
@@ -16,5 +16,4 @@
body {
font-family: "Roboto", sans-serif;
}
</style>

View File

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

View File

@@ -6,12 +6,14 @@
let {
onSuccess,
onError,
onDisconnect,
disabled = false,
size = 'default',
variant = 'primary'
} = $props<{
onSuccess?: (token: string) => void;
onError?: (error: string) => void;
onDisconnect?: () => void;
disabled?: boolean;
size?: 'small' | 'default' | 'large';
variant?: 'primary' | 'secondary';
@@ -21,8 +23,8 @@
let authState = $state(createGoogleAuthState());
let authManager = new GoogleAuthManager(authState);
onMount(() => {
authManager.checkConnection();
onMount(async () => {
await authManager.checkConnection();
});
async function handleConnect() {
@@ -41,6 +43,7 @@
async function handleDisconnect() {
await authManager.disconnectGoogle();
onDisconnect?.();
}
// Size classes
@@ -57,7 +60,14 @@
};
</script>
{#if authState.isConnected}
{#if authState.checking}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 border border-gray-300 whitespace-nowrap">
<div class="w-4 h-4 animate-spin rounded-full border-2 border-current border-t-transparent text-gray-600"></div>
<span class="text-sm font-medium text-gray-800">Checking connection...</span>
</div>
</div>
{:else if authState.isConnected}
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 border border-green-300 whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-600" viewBox="0 0 20 20" fill="currentColor">

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { toast } from '$lib/stores/toast';
import ToastNotification from './ToastNotification.svelte';
// Subscribe to the toast store using Svelte 5 reactivity
let toasts = $derived($toast);
function handleDismiss(id: string) {
toast.remove(id);
}
</script>
<!-- Toast container positioned in top-left corner -->
<div class="fixed top-4 p-2 space-y-3 pointer-events-none max-w-2xl">
{#each toasts as toastItem (toastItem.id)}
<div class="pointer-events-auto">
<ToastNotification
message={toastItem.message}
type={toastItem.type}
duration={toastItem.duration}
onDismiss={() => handleDismiss(toastItem.id)}
/>
</div>
{/each}
</div>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
message,
type = 'error',
duration = 5000,
onDismiss
} = $props<{
message: string;
type?: 'error' | 'success' | 'warning' | 'info';
duration?: number;
onDismiss?: () => void;
}>();
let visible = $state(true);
let timeoutId: ReturnType<typeof setTimeout>;
// Auto-dismiss after specified duration
onMount(() => {
if (duration > 0) {
timeoutId = setTimeout(() => {
dismiss();
}, duration);
}
// Cleanup timeout on component destroy
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function dismiss() {
visible = false;
if (onDismiss) {
onDismiss();
}
}
// Get styles based on toast type
const getToastStyles = (type: string) => {
switch (type) {
case 'success':
return 'border-green-200 bg-green-50 text-green-800';
case 'warning':
return 'border-yellow-200 bg-yellow-50 text-yellow-800';
case 'info':
return 'border-blue-200 bg-blue-50 text-blue-800';
case 'error':
default:
return 'border-red-200 bg-red-50 text-red-800';
}
};
// Get icon SVG path based on toast type
const getIconSvg = (type: string) => {
switch (type) {
case 'success':
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />`;
case 'warning':
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.268 16c-.77.833.192 2.5 1.732 2.5z" />`;
case 'info':
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />`;
case 'error':
default:
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />`;
}
};
</script>
{#if visible && message}
<div
class="rounded-lg border p-4 shadow-lg w-full {getToastStyles(type)}"
role="alert"
aria-live="polite"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="flex-shrink-0">
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
{@html getIconSvg(type)}
</svg>
</div>
<!-- Message -->
<div class="flex-1">
<p class="text-sm font-medium">
{message}
</p>
</div>
<!-- Close button -->
<button
onclick={dismiss}
class="flex-shrink-0 ml-2 text-current opacity-70 hover:opacity-100 transition-opacity"
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Toast has no progress bar as requested -->
</div>
{/if}

View File

@@ -30,7 +30,7 @@ export class GoogleAuthManager {
this.state = state;
}
checkConnection(): void {
async checkConnection(): Promise<void> {
this.state.checking = true;
this.state.error = null;
@@ -38,12 +38,39 @@ export class GoogleAuthManager {
const token = localStorage.getItem('google_refresh_token');
const email = localStorage.getItem('google_user_email');
this.state.isConnected = !!token;
this.state.token = token;
this.state.userEmail = email;
if (!token) {
this.state.isConnected = false;
this.state.token = null;
this.state.userEmail = null;
return;
}
// Verify the token by calling our backend endpoint
const response = await fetch('/private/api/google/auth/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken: token })
});
if (response.ok) {
this.state.isConnected = true;
this.state.token = token;
this.state.userEmail = email;
} else {
// Token is invalid or expired
await this.disconnectGoogle();
if (response.status === 401) {
this.state.error = 'Google session expired. Please reconnect.';
} else {
this.state.error = 'Failed to verify connection.';
}
}
} catch (error) {
console.error('Error checking connection:', error);
this.state.error = 'Failed to check connection status';
this.state.error = 'Failed to verify connection status';
this.state.isConnected = false;
} finally {
this.state.checking = false;
}

View File

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

9
src/lib/helpers.ts Normal file
View File

@@ -0,0 +1,9 @@
export const reactiveQueryArgs = <T>(cb: () => T) => {
const store = writable<T>();
$effect.pre(() => {
store.set(cb());
});
return store;
};

63
src/lib/stores/toast.ts Normal file
View File

@@ -0,0 +1,63 @@
import { writable } from 'svelte/store';
export interface Toast {
id: string;
message: string;
type: 'error' | 'success' | 'warning' | 'info';
duration?: number;
}
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
const store = {
subscribe,
// Add a new toast
add: (toast: Omit<Toast, 'id'>) => {
const id = crypto.randomUUID();
const newToast: Toast = {
id,
duration: 5000, // Default 5 seconds
...toast
};
update(toasts => [...toasts, newToast]);
return id;
},
// Remove a toast by ID
remove: (id: string) => {
update(toasts => toasts.filter(toast => toast.id !== id));
},
// Clear all toasts
clear: () => {
update(() => []);
}
};
// Add convenience methods that reference the same store instance
return {
...store,
// Convenience methods for different toast types
success: (message: string, duration?: number) => {
return store.add({ message, type: 'success', duration });
},
error: (message: string, duration?: number) => {
return store.add({ message, type: 'error', duration });
},
warning: (message: string, duration?: number) => {
return store.add({ message, type: 'warning', duration });
},
info: (message: string, duration?: number) => {
return store.add({ message, type: 'info', duration });
}
};
}
export const toast = createToastStore();

View File

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

View File

@@ -39,5 +39,10 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => {
data: { user },
} = await supabase.auth.getUser()
return { session, supabase, user }
return {
session,
supabase,
user,
profile: data.profile
}
}

View File

@@ -1,5 +1,4 @@
<div class="min-h-screen flex flex-col justify-center items-center">
<!-- SVG QR Code Art on Top -->
<div class="mb-8">
<img class="w-32 h-auto" src="/qr-code.png" alt="">
</div>

View File

@@ -1,21 +1,44 @@
<script lang="ts">
// Add any navbar logic here if needed
import { browser } from '$app/environment';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import ToastContainer from '$lib/components/ToastContainer.svelte';
let { data, children } = $props();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
staleTime: 5 * 60_000, // 5 min cache
refetchOnWindowFocus: false
}
}
});
</script>
<nav class="bg-gray-50 border-b border-gray-300 text-gray-900 p-2">
<div class="container max-w-2xl mx-auto p-2">
<div class="flex items-center justify-between">
<div class="font-bold text-lg">ScanWave</div>
<ul class="flex space-x-4">
<li><a href="/private/home" class="hover:underline">Home</a></li>
<li><a href="/private/scanner" class="hover:underline">Scanner</a></li>
<li><a href="/private/events" class="hover:underline">Events</a></li>
</ul>
</div>
</div>
<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="flex items-center justify-between">
<a href="/private/home" class="text-lg font-bold" aria-label="ScanWave Home">ScanWave</a>
<ul class="flex space-x-4">
<li><a href="/private/scanner">Scanner</a></li>
{#if data.profile?.section_position === 'events_manager'}
<li><a href="/private/events">Events</a></li>
{/if}
</ul>
</div>
</div>
</nav>
<div class="container max-w-2xl mx-auto p-2 bg-white">
<slot />
<div class="container mx-auto max-w-2xl bg-white p-2">
<QueryClientProvider client={queryClient}>
{@render children()}
</QueryClientProvider>
</div>
<ToastContainer />

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { getAuthenticatedClient } from '$lib/google/auth/server';
/**
* @description Verify the validity of a Google refresh token
* @method POST
* @param {Request} request
* @returns {Response}
*/
export async function POST({ request }: { request: Request }): Promise<Response> {
try {
const { refreshToken } = await request.json();
if (!refreshToken) {
return json({ error: 'Refresh token is required' }, { status: 400 });
}
// Get an authenticated client. This will attempt to get a new access token,
// which effectively validates the refresh token.
const oauth2Client = getAuthenticatedClient(refreshToken);
// Attempt to get a new access token
await oauth2Client.getAccessToken();
// If no error is thrown, the token is valid
return json({ success: true });
} catch (error) {
console.error('Failed to verify Google refresh token:', error);
// The token is likely invalid or revoked
return json({ error: 'Invalid or expired refresh token' }, { status: 401 });
}
}

View File

@@ -16,6 +16,16 @@ interface EmailResult {
error?: string;
}
/**
* Replaces template variables in a string with participant data
* Currently supports {name} and {surname} placeholders
*/
function replaceTemplateVariables(template: string, participant: Participant): string {
return template
.replace(/{name}/gi, participant.name || '')
.replace(/{surname}/gi, participant.surname || '');
}
async function generateQRCode(participantId: string): Promise<string> {
const qrCodeBase64 = await QRCode.toDataURL(participantId, {
type: 'image/png',
@@ -38,11 +48,15 @@ async function sendEmailToParticipant(
try {
const qrCodeBase64Data = await generateQRCode(participant.id);
// Replace template variables in subject and body
const personalizedSubject = replaceTemplateVariables(subject, participant);
const personalizedText = replaceTemplateVariables(text, participant);
// Send email with QR code
await sendGmail(refreshToken, {
to: participant.email,
subject: subject,
text: text,
subject: personalizedSubject,
text: personalizedText,
qr_code: qrCodeBase64Data
});

View File

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

View File

@@ -0,0 +1,50 @@
<script lang="ts">
// Get the profile from the page data if available
let { data } = $props();
let profile = $derived(data.profile);
</script>
<div class="flex flex-col items-center justify-center min-h-[70vh] p-6">
<div class="rounded-lg border border-gray-300 p-6 max-w-md w-full flex flex-col gap-6 text-center">
<div class="flex flex-col items-center gap-2">
<div class="text-red-600 bg-red-50 p-3 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<h1 class="text-2xl font-semibold text-gray-800">Access Denied</h1>
<p class="text-gray-600">You don't have permission to access the events section.</p>
</div>
<div class="flex flex-col gap-4">
{#if profile}
<p class="text-sm text-gray-500">
Your current role: <span class="font-semibold">{profile.section_position || 'Not assigned'}</span>
</p>
{#if profile.section}
<p class="text-sm text-gray-500">
Section: <span class="font-semibold">{profile.section.name}</span>
</p>
{/if}
{/if}
<p class="text-gray-600">
You need the <span class="font-semibold">events_manager</span> role to access this section.
Please contact your administrator for assistance.
</p>
</div>
<div class="flex flex-col gap-3">
<a href="/private/home" class="rounded-md px-4 py-2 bg-blue-600 text-white">
Go to Dashboard
</a>
<button
onclick={() => window.history.back()}
class="rounded-md px-4 py-2 border border-gray-300 text-gray-700"
aria-label="Go back"
>
Go Back
</button>
</div>
</div>
</div>

View File

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

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
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';
@@ -15,19 +15,11 @@
let { data } = $props();
// Step management
let currentStep = $state(0); // Start at step 0 for Google auth check
const totalSteps = 4; // Increased to include auth step
let currentStep = $state(0);
const totalSteps = 4;
// Step 0: Google Auth
let authData = $state({
isConnected: false,
checking: true,
connecting: false,
showCancelOption: false,
token: null as string | null,
error: null as string | null,
userEmail: null as string | null
});
let isGoogleConnected = $state(false);
// Step 1: Event Details
let eventData = $state({
@@ -41,13 +33,13 @@
selectedSheet: null as GoogleSheet | null,
sheetData: [] as string[][],
columnMapping: {
name: 0, // Initialize to 0 (no column selected)
name: 0,
surname: 0,
email: 0,
confirmation: 0
},
loading: false,
expandedSheetList: true // Add this flag to control sheet list expansion
expandedSheetList: true
});
// Step 3: Email
@@ -61,189 +53,11 @@
let errors = $state<Record<string, string>>({});
onMount(async () => {
// Check Google auth status on mount
await checkGoogleAuth();
if (currentStep === 2) {
await loadRecentSheets();
}
});
// Google Auth functions
async function checkGoogleAuth() {
authData.checking = true;
try {
const accessToken = localStorage.getItem('google_access_token');
const refreshToken = localStorage.getItem('google_refresh_token');
if (accessToken && refreshToken) {
// Check if token is still valid
const isValid = await isTokenValid(accessToken);
authData.isConnected = isValid;
authData.token = accessToken;
if (isValid) {
// Fetch user info
await fetchUserInfo(accessToken);
}
} else {
authData.isConnected = false;
authData.userEmail = null;
}
} catch (error) {
console.error('Error checking Google auth:', error);
authData.isConnected = false;
authData.error = 'Error checking Google connection';
authData.userEmail = null;
} finally {
authData.checking = false;
}
}
async function connectToGoogle() {
authData.error = '';
authData.connecting = true;
try {
// Open popup window for OAuth
const popup = window.open(
'/auth/google',
'google-auth',
'width=500,height=600,scrollbars=yes,resizable=yes,left=' +
Math.round(window.screen.width / 2 - 250) + ',top=' +
Math.round(window.screen.height / 2 - 300)
);
if (!popup) {
authData.error = 'Failed to open popup window. Please allow popups for this site.';
authData.connecting = false;
return;
}
let authCompleted = false;
let popupTimer: number | null = null;
let cancelTimeout: number | null = null;
// Store current timestamp to detect changes in localStorage
const startTimestamp = localStorage.getItem('google_auth_timestamp') || '0';
// Poll localStorage for auth completion
const pollInterval = setInterval(() => {
try {
const currentTimestamp = localStorage.getItem('google_auth_timestamp');
// If timestamp has changed, auth is complete
if (currentTimestamp && currentTimestamp !== startTimestamp) {
handleAuthSuccess();
}
} catch (e) {
console.error('Error checking auth timestamp:', e);
}
}, 500); // Poll every 500ms
// Common handler for authentication success
function handleAuthSuccess() {
if (authCompleted) return; // Prevent duplicate handling
authCompleted = true;
authData.connecting = false;
authData.showCancelOption = false;
// Clean up timers
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
// Update auth state
setTimeout(checkGoogleAuth, 100);
}
// Clean up function to handle all cleanup in one place
const cleanUp = () => {
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
authData.connecting = false;
};
// Set a timeout for initial auth check
popupTimer = setTimeout(() => {
// Only check if auth isn't already completed
if (!authCompleted) {
cleanUp();
// Check if tokens were stored by the popup before it was closed
setTimeout(checkGoogleAuth, 100);
}
}, 30 * 1000) as unknown as number; // Reduced from 60s to 30s
// Show cancel option sooner
cancelTimeout = setTimeout(() => {
if (!authCompleted) {
authData.showCancelOption = true;
}
}, 10 * 1000) as unknown as number; // Reduced from 20s to 10s
// Final cleanup timeout
setTimeout(() => {
if (!authCompleted) {
cleanUp();
}
}, 60 * 1000); // Reduced from 3min to 1min
} catch (error) {
console.error('Error connecting to Google:', error);
authData.error = 'Failed to connect to Google';
authData.connecting = false;
}
}
function cancelGoogleAuth() {
authData.connecting = false;
authData.showCancelOption = false;
}
async function fetchUserInfo(accessToken: string) {
try {
// Use the new getUserInfo function from our lib
const userData = await getUserInfo(accessToken);
if (userData) {
authData.userEmail = userData.email;
} else {
authData.userEmail = null;
}
} catch (error) {
console.error('Error fetching user info:', error);
authData.userEmail = null;
}
}
async function disconnectGoogle() {
try {
// First revoke the token at Google using our API
const accessToken = localStorage.getItem('google_access_token');
if (accessToken) {
await revokeToken(accessToken);
}
// Remove tokens from local storage
localStorage.removeItem('google_access_token');
localStorage.removeItem('google_refresh_token');
// Update auth state
authData.isConnected = false;
authData.token = null;
authData.userEmail = null;
// Clear any selected sheets data
sheetsData.availableSheets = [];
sheetsData.selectedSheet = null;
sheetsData.sheetData = [];
} catch (error) {
console.error('Error disconnecting from Google:', error);
authData.error = 'Failed to disconnect from Google';
}
}
// Step navigation
function nextStep() {
if (validateCurrentStep()) {
@@ -259,49 +73,65 @@
}
function validateCurrentStep(): boolean {
// Clear previous errors
errors = {};
let isValid = true;
if (currentStep === 0) {
if (!authData.isConnected) {
if (!isGoogleConnected) {
toast.error('Please connect your Google account to continue');
errors.auth = 'Please connect your Google account to continue';
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 +139,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 +162,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`, {
// Use the new unified API endpoint, requesting only a preview range
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data?range=A1:Z10`, {
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 +191,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 +230,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;
}
@@ -399,60 +250,50 @@
// Computed values
let canProceed = $derived(() => {
if (currentStep === 0) return authData.isConnected;
if (currentStep === 1) return eventData.name && eventData.date;
if (currentStep === 0) return isGoogleConnected;
if (currentStep === 1) return !!(eventData.name && eventData.date);
if (currentStep === 2) {
const { name, surname, email, confirmation } = sheetsData.columnMapping;
return sheetsData.selectedSheet && name && surname && email && confirmation;
return !!(sheetsData.selectedSheet && name && surname && email && confirmation);
}
if (currentStep === 3) return emailData.subject && emailData.body;
if (currentStep === 3) return !!(emailData.subject && emailData.body);
return false;
});
</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={() => (isGoogleConnected = true)}
onDisconnect={() => (isGoogleConnected = false)}
onError={(err) => toast.error(err)}
/>
{: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={canProceed()}
{loading}
{prevStep}
{nextStep}
{createEvent}
/>

View File

@@ -1,43 +1,67 @@
<script lang="ts">
let { emailData = $bindable(), errors = $bindable() } = $props<{
let { emailData = $bindable() } = $props<{
emailData: {
subject: string;
body: string;
};
errors: Record<string, string>;
}>();
const templateVariables = [
{ name: '{name}', description: "Participant's first name" },
{ name: '{surname}', description: "Participant's last name" }
];
const subjectTemplatesDetected = $derived(
templateVariables.filter((v) => emailData.subject && emailData.subject.includes(v.name))
);
const bodyTemplatesDetected = $derived(
templateVariables.filter((v) => emailData.body && emailData.body.includes(v.name))
);
</script>
<div class="space-y-6">
<div>
<label for="emailSubject" class="block text-sm font-medium text-gray-700 mb-2">
<label for="emailSubject" class="mb-2 block text-sm font-medium text-gray-700">
Email Subject *
</label>
<input
id="emailSubject"
type="text"
bind:value={emailData.subject}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class="w-full rounded border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Event invitation subject"
/>
{#if errors.subject}
<p class="mt-1 text-sm text-red-600">{errors.subject}</p>
{#if subjectTemplatesDetected.length > 0}
<p class="mt-1 text-xs text-gray-500">
Detected templates: {subjectTemplatesDetected.map((v) => v.name).join(', ')}
</p>
{/if}
</div>
<div>
<label for="emailBody" class="block text-sm font-medium text-gray-700 mb-2">
<label for="emailBody" class="mb-2 block text-sm font-medium text-gray-700">
Email Body *
</label>
<textarea
id="emailBody"
bind:value={emailData.body}
rows="8"
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class="w-full rounded border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Email message content..."
></textarea>
{#if errors.body}
<p class="mt-1 text-sm text-red-600">{errors.body}</p>
{#if bodyTemplatesDetected.length > 0}
<p class="text-xs text-gray-500">
Detected templates: {bodyTemplatesDetected.map((v) => v.name).join(', ')}
</p>
{/if}
</div>
<div>
<p class="mt-2 mb-2 block text-sm font-medium text-gray-700">Tip:</p>
<p class="text-xs text-gray-500">
Use <code class="rounded bg-gray-100 px-1 py-0.5 text-xs">&#123;name&#125;</code> and
<code class="rounded bg-gray-100 px-1 py-0.5 text-xs">&#123;surname&#125;</code> to personalize
your message. Works for both subject and body. (e.g., "Hello &#123;name&#125;, welcome to our event!")
</p>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,8 @@
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 { toast } from '$lib/stores/toast.js';
let { data } = $props();
@@ -47,9 +47,9 @@
let participantsLoading = $state(true);
let syncingParticipants = $state(false);
let sendingEmails = $state(false);
let updatingEmail = $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,7 @@
event = eventData;
} catch (err) {
console.error('Error loading event:', err);
error = 'Failed to load event';
toast.error('Failed to load event');
} finally {
loading = false;
}
@@ -95,24 +95,28 @@
participants = participantsData || [];
} catch (err) {
console.error('Error loading participants:', err);
error = 'Failed to load participants';
toast.error('Failed to load participants');
} finally {
participantsLoading = false;
}
}
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
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
error = 'Please connect your Google account first to sync participants';
toast.error('Please connect your Google account first to sync participants');
return;
}
syncingParticipants = true;
error = '';
const previousCount = participants.length; // Capture count before sync
try {
// Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
@@ -175,9 +179,26 @@
// Reload participants
await loadParticipants();
// Show success message with accurate count of changes
const newCount = participants.length;
const diff = newCount - previousCount;
const processedCount = names.length;
let message = `Sync complete. ${processedCount} confirmed entries processed from the sheet.`;
if (diff > 0) {
message += ` ${diff} new participants added.`;
} else if (diff < 0) {
message += ` ${-diff} participants removed.`;
} else {
message += ` No changes to the participant list.`;
}
toast.success(message, 6000);
} catch (err) {
console.error('Error syncing participants:', err);
error = 'Failed to sync participants';
toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
syncingParticipants = false;
}
@@ -189,20 +210,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 +261,76 @@
});
} 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;
}
}
// 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() {
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}
@@ -289,7 +358,12 @@ onSyncParticipants={syncParticipants}
/>
</div>
<EmailTemplate {event} {loading} />
<EmailTemplate
{event}
{loading}
{updatingEmail}
onUpdateEmail={handleEmailUpdate}
/>
<EmailSending
{loading}
@@ -303,7 +377,3 @@ onSyncParticipants={syncParticipants}
{#if emailResults}
<EmailResults {emailResults} />
{/if}
{#if error}
<ErrorMessage {error} />
{/if}

View File

@@ -1,18 +1,104 @@
<script lang="ts">
interface Event {
id: string;
email_subject: string;
email_body: string;
}
let { event, loading } = $props<{
let {
event,
loading,
updatingEmail,
onUpdateEmail
} = $props<{
event: Event | null;
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>
<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>
{#if !loading && event}
<div class="flex gap-3">
{#if isEditing}
<button
onclick={handleSave}
disabled={updatingEmail}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Save email template"
>
{updatingEmail ? 'Saving...' : 'Save'}
</button>
<button
onclick={toggleEdit}
class="rounded border border-gray-300 bg-white px-4 py-2 text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={updatingEmail}
aria-label="Cancel editing"
>
Cancel
</button>
{:else}
<button
onclick={toggleEdit}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={updatingEmail}
aria-label="Edit email template"
>
Edit Email
</button>
{/if}
</div>
{/if}
</div>
{#if loading}
@@ -31,17 +117,34 @@
{:else if event}
<div class="space-y-4">
<div>
<span class="block mb-1 text-sm font-medium text-gray-700">Subject:</span>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<p class="text-sm text-gray-900">{event.email_subject}</p>
</div>
<label for="emailSubject" class="block mb-1 text-sm font-medium text-gray-700">Subject:</label>
<input
id="emailSubject"
type="text"
bind:value={emailSubject}
disabled={!isEditing || updatingEmail}
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-blue-500 disabled:cursor-default disabled:bg-gray-100"
/>
</div>
<div>
<span class="block mb-1 text-sm font-medium text-gray-700">Body:</span>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200 max-h-48 overflow-y-auto">
<p class="text-sm whitespace-pre-wrap text-gray-900">{event.email_body}</p>
</div>
<label for="emailBody" class="block mb-1 text-sm font-medium text-gray-700">Body:</label>
<textarea
id="emailBody"
bind:value={emailBody}
rows="6"
disabled={!isEditing || updatingEmail}
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-blue-500 disabled:cursor-default disabled:bg-gray-100"
></textarea>
{#if isEditing}
<div class="mt-2 text-xs text-gray-500">
Template variables: <span class="font-mono bg-gray-100 px-1 rounded">&#123;name&#125;</span>,
<span class="font-mono bg-gray-100 px-1 rounded">&#123;surname&#125;</span>
</div>
{/if}
</div>
<!-- Save button moved to the header -->
</div>
{/if}
</div>

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { toast } from '$lib/stores/toast.js';
interface Participant {
id: string;
name: string;
@@ -28,13 +30,23 @@
syncingParticipants: boolean;
onSyncParticipants: () => void;
}>();
// Handle sync participants with toast notifications
function handleSyncParticipants() {
// Show initial notification about sync starting
toast.info('Starting participant synchronization...', 5000);
// Call the parent component's sync function
onSyncParticipants();
}
</script>
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Participants</h2>
<button
onclick={onSyncParticipants}
onclick={handleSyncParticipants}
disabled={syncingParticipants || !event || loading}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
message,
type = 'error',
duration = 5000,
onDismiss
} = $props<{
message: string;
type?: 'error' | 'success' | 'warning' | 'info';
duration?: number;
onDismiss?: () => void;
}>();
let visible = $state(true);
let timeoutId: ReturnType<typeof setTimeout>;
// Auto-dismiss after specified duration
onMount(() => {
if (duration > 0) {
timeoutId = setTimeout(() => {
dismiss();
}, duration);
}
// Cleanup timeout on component destroy
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function dismiss() {
visible = false;
if (onDismiss) {
onDismiss();
}
}
// Get styles based on toast type
const getToastStyles = (type: string) => {
const baseStyles = "fixed top-4 left-4 z-50 p-4 rounded-lg shadow-lg border max-w-sm";
switch (type) {
case 'success':
return `${baseStyles} bg-green-50 border-green-200 text-green-800`;
case 'warning':
return `${baseStyles} bg-yellow-50 border-yellow-200 text-yellow-800`;
case 'info':
return `${baseStyles} bg-blue-50 border-blue-200 text-blue-800`;
case 'error':
default:
return `${baseStyles} bg-red-50 border-red-200 text-red-800`;
}
};
const getIconSvg = (type: string) => {
switch (type) {
case 'success':
return `<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />`;
case 'warning':
return `<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />`;
case 'info':
return `<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />`;
case 'error':
default:
return `<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />`;
}
};
</script>
{#if visible}
<div class={getToastStyles(type)} role="alert">
<div class="flex items-start gap-3">
<!-- Icon -->
<svg
class="h-5 w-5 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
{@html getIconSvg(type)}
</svg>
<!-- Message -->
<div class="flex-1">
<p class="text-sm font-medium">{message}</p>
</div>
<!-- Close button -->
<button
onclick={dismiss}
class="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,71 @@
import type { SupabaseClient } from '@supabase/supabase-js';
export interface Event {
id: string;
name: string;
date: string;
archived: boolean;
}
/**
* Unified function to get events or search events based on a search term
* @param supabase The Supabase client
* @param searchTerm Optional search term - if provided, will filter by name
* @returns Combined array of regular and archived events
*/
export async function getEvents(supabase: SupabaseClient, searchTerm: string = '') {
try {
const searchPattern = searchTerm.trim() ? `%${searchTerm}%` : null;
// Build regular events query
let regularQuery = supabase
.from('events')
.select('id, name, date')
.order('date', { ascending: false });
// Apply search filter if needed
if (searchPattern) {
regularQuery = regularQuery.ilike('name', searchPattern);
}
// Fetch regular events
const { data: regularEvents, error: regularError } = await regularQuery;
if (regularError) throw regularError;
// Build archived events query
let archivedQuery = supabase
.from('events_archived')
.select('id, name, date')
.order('date', { ascending: false })
.limit(searchPattern ? 50 : 20); // Fetch more when searching
// Apply search filter if needed
if (searchPattern) {
archivedQuery = archivedQuery.ilike('name', searchPattern);
}
// Fetch archived events
const { data: archivedEvents, error: archivedError } = await archivedQuery;
if (archivedError) throw archivedError;
// Merge both arrays, marking archived events
const regularMapped = (regularEvents || []).map((event) => ({ ...event, archived: false }));
const archivedMapped = (archivedEvents || []).map((event) => ({
...event,
archived: true
}));
// Sort all events by date (newest first)
const combined = [...regularMapped, ...archivedMapped];
combined.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return combined;
} catch (error) {
console.error('Error fetching events:', error);
throw new Error(searchTerm.trim()
? `Failed to search events for "${searchTerm}"`
: 'Failed to load events');
}
}

View File

@@ -1,22 +0,0 @@
// src/routes/my-page/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
// get the logged-in user
const { data: { user }, error: authError } = await locals.supabase.auth.getUser();
const { data: user_profile, error: profileError } = await locals.supabase.from('profiles').select('*, section:sections (id, name)').eq('id', user?.id).single();
if (authError) {
console.error('Supabase auth error:', authError);
throw new Error('Could not get user');
}
if (profileError) {
console.error('Supabase profile error:', profileError);
throw new Error('Could not get user profile');
}
return { user, user_profile };
};

View File

@@ -1,51 +1,73 @@
<script lang="ts">
import type { User } from '@supabase/supabase-js';
export let data: {
user: User | null,
user_profile: any | null
};
let { data } = $props();
</script>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">User Profile</h1>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">User Dashboard</h1>
<div class="mb-4 rounded border border-gray-300 bg-white p-6">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3 mb-4">
<div class="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-600">
{data.user?.user_metadata.display_name?.[0] ?? "U"}
</div>
<div>
<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>
</div>
</div>
<div class="flex flex-col gap-1">
<div>
<span class="font-medium text-gray-700">Section:</span>
<span class="text-gray-900">{data.user_profile?.section.name ?? "N/A"}</span>
</div>
<div>
<span class="font-medium text-gray-700">Position:</span>
<span class="text-gray-900">{data.user_profile?.section_position ?? "N/A"}</span>
</div>
</div>
<h2 class="text-lg mb-2 mt-4">User guide</h2>
<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.
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.
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.
</p>
<h2 class="text-lg mb-2 mt-4">Administrator guide</h2>
<p class="text-gray-700 text-sm leading-relaxed">
You can view events
</p>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column: User Profile -->
<div class="lg:col-span-1">
<div class="flex h-full flex-col rounded-lg border border-gray-300 bg-white p-6">
<div class="flex flex-grow flex-col items-center text-center">
<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"
>
{data.profile?.display_name?.[0]?.toUpperCase() ?? 'U'}
</div>
<h2 class="text-xl font-semibold text-gray-900">{data.profile?.display_name}</h2>
<p class="text-sm text-gray-500">{data.user?.email}</p>
</div>
<div class="mt-6 text-center">
<a
href="/auth/signout"
class="text-sm text-red-500 transition hover:text-red-700 hover:underline">Sign Out</a
>
</div>
</div>
</div>
<!-- Right Column: Information -->
<div class="space-y-6 lg:col-span-2">
<!-- Role Information -->
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Your Role</h2>
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Section</dt>
<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>
<a
href="/auth/signout"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-red-500 hover:bg-red-600 text-white font-semibold py-3 px-8 rounded-full shadow-none border border-gray-300 transition"
>
Sign out
</a>

View File

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

View File

@@ -45,9 +45,9 @@
});
</script>
<div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded-lg border border-gray-300"></div>
<div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded"></div>
<style>
<style lang="postcss">
/* Hide unwanted icons */
#qr-scanner :global(img[alt='Info icon']),
#qr-scanner :global(img[alt='Camera based scan']) {
@@ -58,21 +58,51 @@
color: black !important;
}
/* Change camera permission button text */
#qr-scanner :global(#html5-qrcode-button-camera-permission) {
visibility: hidden;
}
#qr-scanner :global(#html5-qrcode-button-camera-permission::after) {
position: absolute;
inset: auto 0 0;
display: block;
content: 'Allow camera access';
visibility: visible;
padding: 10px 0;
}
#qr-scanner :global(#qr-scanner__scan_region) {
min-height: auto !important;
aspect-ratio: 1 !important;
}
#qr-scanner :global(button.html5-qrcode-element) {
border-radius: 0.375rem;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
background-color: #2563eb;
color: #fff;
font-weight: 500;
transition-property: background-color, border-color, color, fill, stroke;
transition-duration: 150ms;
outline: none;
}
#qr-scanner :global(button.html5-qrcode-element:hover) {
background-color: #1d4ed8;
}
#qr-scanner :global(button.html5-qrcode-element:focus) {
box-shadow: 0 0 0 2px #60a5fa, 0 0 0 4px #fff;
}
#qr-scanner :global(input) {
border-radius: 0.375rem; /* rounded-md */
border-width: 1px;
border-color: #d1d5db; /* border-gray-300 */
padding-left: 1rem; /* px-4 */
padding-right: 1rem;
padding-top: 0.5rem; /* py-2 */
padding-bottom: 0.5rem;
background-color: #f9fafb; /* bg-gray-50 */
color: #111827; /* text-gray-900 */
font-size: 1rem;
line-height: 1.5rem;
outline: none;
transition-property: border-color, box-shadow;
transition-duration: 150ms;
}
#qr-scanner :global(input:focus) {
border-color: #2563eb; /* border-blue-600 */
box-shadow: 0 0 0 2px #60a5fa;
}
</style>

View File

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

View File

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

View File

@@ -64,7 +64,8 @@ self.addEventListener('fetch', (event) => {
throw new Error('invalid response from fetch');
}
if (response.status === 200) {
// Do not cache private pages
if (response.status === 200 && !url.pathname.startsWith('/private')) {
cache.put(event.request, response.clone());
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

364
styling.md Normal file
View File

@@ -0,0 +1,364 @@
# ScanWave Styling Guide
This document outlines the design system and styling conventions used in the ScanWave application. Use this as a reference when creating new applications with similar visual design.
## Table of Contents
- [Color Palette](#color-palette)
- [Typography](#typography)
- [Layout Patterns](#layout-patterns)
- [Component Patterns](#component-patterns)
- [Form Elements](#form-elements)
- [Buttons](#buttons)
- [Cards and Containers](#cards-and-containers)
- [Navigation](#navigation)
- [Tables](#tables)
- [Loading States](#loading-states)
- [Toast Notifications](#toast-notifications)
- [Responsive Design](#responsive-design)
## Color Palette
### Primary Colors
- **Blue**: Primary action color
- `bg-blue-600` / `text-blue-600` - Primary buttons, links
- `bg-blue-700` / `text-blue-700` - Hover states
- `bg-blue-50` / `text-blue-800` - Info notifications
- `border-blue-600` / `focus:ring-blue-600` - Focus states
### Gray Scale
- **Text Colors**:
- `text-gray-900` - Primary text (headings, important content)
- `text-gray-700` - Secondary text (labels, descriptions)
- `text-gray-500` - Tertiary text (metadata, placeholders)
- **Background Colors**:
- `bg-white` - Main content backgrounds
- `bg-gray-50` - Page backgrounds, subtle sections
- `bg-gray-100` - Disabled form fields
- `bg-gray-200` - Loading placeholders
- **Border Colors**:
- `border-gray-300` - Standard borders (cards, inputs)
- `border-gray-200` - Subtle borders (table rows)
### Status Colors
- **Success**: `bg-green-50 text-green-800 border-green-300`
- **Warning**: `bg-yellow-50 text-yellow-800 border-yellow-300`
- **Error**: `bg-red-50 text-red-800 border-red-300`
- **Info**: `bg-blue-50 text-blue-800 border-blue-300`
### Accent Colors
- **Red**: `text-red-600` / `hover:text-red-700` - Danger actions (sign out)
- **Green**: `text-green-600` - Success indicators
## Typography
### Headings
```html
<!-- Page titles -->
<h1 class="mb-6 text-2xl font-bold text-center text-gray-800">Page Title</h1>
<!-- Section headings -->
<h2 class="mb-4 text-xl font-semibold text-gray-900">Section Title</h2>
<h2 class="mb-2 text-lg font-semibold text-gray-900">Subsection Title</h2>
```
### Body Text
```html
<!-- Primary text -->
<p class="text-sm text-gray-900">Important content</p>
<!-- Secondary text -->
<p class="text-sm text-gray-700">Regular content</p>
<p class="text-sm leading-relaxed text-gray-700">Longer content blocks</p>
<!-- Metadata/labels -->
<span class="text-sm font-medium text-gray-500">Label</span>
<span class="text-sm font-medium text-gray-700">Form Label</span>
<!-- Small text -->
<p class="text-xs text-gray-500">Helper text</p>
```
### Text Utilities
- **Font Weight**: `font-bold`, `font-semibold`, `font-medium`
- **Text Alignment**: `text-center`, `text-left`
- **Line Height**: `leading-relaxed` for longer text blocks
## Layout Patterns
### Container Pattern
```html
<div class="container mx-auto max-w-2xl bg-white p-4">
<!-- Content -->
</div>
```
### Grid Layouts
```html
<!-- Dashboard grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-1"><!-- Sidebar --></div>
<div class="lg:col-span-2"><!-- Main content --></div>
</div>
<!-- Two-column responsive -->
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Items -->
</dl>
```
### Spacing
- **Standard spacing**: `space-y-6`, `gap-6` - Between major sections
- **Component spacing**: `mb-4`, `mt-6`, `p-6` - Around components
- **Small spacing**: `gap-3`, `mb-2`, `mt-2` - Between related elements
- **Container padding**: `p-4`, `p-6` - Internal container spacing
## Component Patterns
### Card Structure
```html
<div class="rounded-lg border border-gray-300 bg-white p-6">
<div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Title</h2>
<!-- Actions -->
</div>
<!-- Content -->
</div>
```
### Avatar/Profile Picture
```html
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-4xl font-bold text-gray-600">
{initials}
</div>
```
## Form Elements
### Input Fields
```html
<!-- Standard input -->
<input
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-600 focus:ring-blue-600 focus:outline-none"
type="text"
/>
<!-- Disabled input -->
<input
class="w-full rounded-md border border-gray-300 px-3 py-2 disabled:cursor-default disabled:bg-gray-100"
disabled
/>
```
### Textarea
```html
<textarea
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-600 focus:ring-blue-600 focus:outline-none"
rows="6"
></textarea>
```
### Select Dropdown
```html
<select class="w-full rounded-md border border-gray-300 p-2 focus:ring-2 focus:ring-blue-600 focus:outline-none">
<option>Option</option>
</select>
```
### Form Labels
```html
<label class="block mb-1 text-sm font-medium text-gray-700">Label Text</label>
```
## Buttons
### Primary Buttons
```html
<button class="rounded-md bg-blue-600 px-4 py-2 text-white font-medium transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50">
Primary Action
</button>
```
### Secondary/Outline Buttons
```html
<button class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-700 font-medium transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50">
Secondary Action
</button>
```
### Danger/Red Buttons
```html
<button class="rounded-md bg-red-600 px-4 py-2 text-white font-medium transition hover:bg-red-700">
Danger Action
</button>
```
### Button States
- **Loading**: Replace text with "Loading..." or "Saving..."
- **Disabled**: `disabled:cursor-not-allowed disabled:opacity-50`
## Cards and Containers
### Standard Card
```html
<div class="mb-6 rounded-md border border-gray-300 bg-white p-6">
<!-- Content -->
</div>
```
### Card with Header Actions
```html
<div class="rounded-md border border-gray-300 bg-white p-6">
<div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Title</h2>
<div class="flex gap-3">
<!-- Action buttons -->
</div>
</div>
<!-- Content -->
</div>
```
## Navigation
### Top Navigation
```html
<nav class="border-b border-gray-300 bg-gray-50 p-4 text-gray-900">
<div class="container mx-auto max-w-2xl">
<div class="flex items-center justify-between">
<a href="/" class="text-lg font-bold">App Name</a>
<ul class="flex space-x-4">
<li><a href="/page" class="hover:text-blue-600 transition">Page</a></li>
</ul>
</div>
</div>
</nav>
```
## Tables
### Standard Table
```html
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Header</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-900">Data</td>
</tr>
</tbody>
</table>
</div>
```
### Definition List (Key-Value Pairs)
```html
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Key</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">Value</dd>
</div>
</dl>
```
## Loading States
### Skeleton Loading
```html
<div class="space-y-4">
<div class="h-4 animate-pulse rounded-md bg-gray-200"></div>
<div class="h-10 w-full animate-pulse rounded-md bg-gray-200"></div>
</div>
```
### Loading Spinner
```html
<div class="flex h-10 items-center justify-center">
<div class="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
</div>
```
## Toast Notifications
### Toast Container Structure
```html
<div class="rounded-md border p-4 shadow-lg w-full {colorClasses}">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<!-- Icon -->
</div>
<div class="flex-1">
<p class="text-sm font-medium">{message}</p>
</div>
<button class="flex-shrink-0">
<!-- Close button -->
</button>
</div>
</div>
```
### Toast Color Variants
- **Success**: `border-green-300 bg-green-50 text-green-800`
- **Warning**: `border-yellow-300 bg-yellow-50 text-yellow-800`
- **Info**: `border-blue-300 bg-blue-50 text-blue-800`
- **Error**: `border-red-300 bg-red-50 text-red-800`
## Responsive Design
### Breakpoints
- **Mobile First**: Default styles for mobile
- **sm**: `sm:` prefix for small screens and up
- **lg**: `lg:` prefix for large screens and up
### Common Responsive Patterns
```html
<!-- Responsive grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Responsive padding -->
<div class="p-4 sm:p-6">
<!-- Responsive columns -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
```
## Common Utility Classes
### Flexbox
- `flex items-center justify-between` - Header with title and actions
- `flex items-start gap-3` - Toast notification layout
- `flex flex-col` - Vertical stacking
- `flex-grow` - Fill available space
### Positioning
- `relative` / `absolute` - Positioning contexts
- `fixed bottom-6 left-1/2 -translate-x-1/2` - Centered fixed positioning
### Visibility
- `hidden` / `block` - Show/hide elements
- `overflow-hidden` - Clip content
- `overflow-x-auto` - Horizontal scroll for tables
### Borders and Shadows
- `rounded-md` - Standard border radius for all components
- `rounded-full` - Circular elements (avatars)
- `shadow-lg` - Toast notifications and elevated elements
- `shadow-none` - Remove default shadows when needed
## Design Tokens Summary
### Standardized Values
- **Border Radius**: `rounded-md` (6px) for all rectangular components
- **Border Colors**: `border-gray-300` (standard), `border-gray-200` (subtle)
- **Focus States**: `focus:border-blue-600 focus:ring-blue-600`
- **Spacing**: `gap-4` (1rem), `gap-6` (1.5rem), `p-4` (1rem), `p-6` (1.5rem)
- **Font Weights**: `font-medium` for buttons and emphasis, `font-semibold` for headings, `font-bold` for titles
- **Status Border**: All status colors use `-300` shade for borders (e.g., `border-green-300`)
This styling guide captures the core design patterns used throughout the ScanWave application. Follow these conventions to maintain visual consistency across your new applications.