17 Commits

Author SHA1 Message Date
Roman Krček
e616165a77 Fix service worker mess 2025-09-03 10:38:30 +02:00
Roman Krček
238d2eebc5 Fix worker reloads 2025-09-03 10:22:59 +02:00
Roman Krček
aedf260551 Fixed problem where auth is bypassed 2025-09-03 10:17:20 +02:00
Roman Krček
f1179ddc09 Fix when people order multiple times 2025-09-03 08:34:22 +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
fa685e6ba9 Added loading indicator 2025-09-02 18:21:18 +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
21 changed files with 759 additions and 424 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "scan-wave",
"private": true,
"version": "0.0.1",
"version": "0.0.2",
"type": "module",
"scripts": {
"dev": "vite dev",

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

@@ -89,7 +89,7 @@ const authGuard: Handle = async ({ event, resolve }) => {
}
if (!event.locals.session && event.url.pathname.startsWith('/private')) {
redirect(303, '/auth')
redirect(303, '/auth/login')
}
if (event.locals.session && event.url.pathname === '/auth') {

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

@@ -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;
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,20 +51,43 @@ 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'
range?: string
): Promise<SheetData> {
const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth });
let effectiveRange = range;
// 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
range: effectiveRange
});
return {
@@ -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 (

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

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

@@ -1,6 +1,5 @@
<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';
@@ -16,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({
@@ -42,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
@@ -62,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()) {
@@ -265,7 +78,7 @@
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;
@@ -359,8 +172,8 @@
}
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')}`
@@ -437,13 +250,13 @@
// 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>
@@ -455,16 +268,9 @@
<div class="mb-4 rounded border border-gray-300 bg-white p-6">
{#if currentStep === 0}
<GoogleAuthStep
onSuccess={(token) => {
authData.error = null;
authData.token = token;
authData.isConnected = true;
setTimeout(checkGoogleAuth, 100);
}}
onError={(error) => {
authData.error = error;
authData.isConnected = false;
}}
onSuccess={() => (isGoogleConnected = true)}
onDisconnect={() => (isGoogleConnected = false)}
onError={(err) => toast.error(err)}
/>
{:else if currentStep === 1}
<EventDetailsStep bind:eventData />
@@ -485,7 +291,7 @@
<StepNavigation
{currentStep}
{totalSteps}
{canProceed}
canProceed={canProceed()}
{loading}
{prevStep}
{nextStep}

View File

@@ -2,9 +2,10 @@
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props
let { onSuccess, onError } = $props<{
let { onSuccess, onError, onDisconnect } = $props<{
onSuccess?: (token: string) => void;
onError?: (error: string) => void;
onDisconnect?: () => void;
}>();
</script>
@@ -18,8 +19,9 @@
<GoogleAuthButton
size="large"
variant="primary"
onSuccess={onSuccess}
onError={onError}
{onSuccess}
{onError}
{onDisconnect}
/>
</div>
</div>

View File

@@ -115,6 +115,8 @@
}
syncingParticipants = true;
const previousCount = participants.length; // Capture count before sync
try {
// Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
@@ -136,35 +138,64 @@
if (rows.length === 0) throw new Error('No data found in sheet');
// Extract participant data based on column mapping
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
// --- Start of new logic to handle duplicates ---
// Skip header row (start from index 1)
// First, extract all potential participants from the sheet
const potentialParticipants = [];
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
if (row.length > 0) {
const name = row[event.name_column - 1] || '';
const surname = row[event.surname_column - 1] || '';
const email = row[event.email_column - 1] || '';
const email = (row[event.email_column - 1] || '').trim();
const confirmation = row[event.confirmation_column - 1] || '';
// Only add if the row has meaningful data (not all empty) AND confirmation is TRUE
const isConfirmed =
confirmation.toString().toLowerCase() === 'true' ||
confirmation.toString().toLowerCase() === 'yes' ||
confirmation === '1' ||
confirmation === 'x';
if ((name.trim() || surname.trim() || email.trim()) && isConfirmed) {
names.push(name.trim());
surnames.push(surname.trim());
emails.push(email.trim());
if ((name.trim() || surname.trim() || email) && isConfirmed) {
potentialParticipants.push({ name: name.trim(), surname: surname.trim(), email });
}
}
}
// Create a map to count occurrences of each unique participant combination
const participantCounts = new Map<string, number>();
for (const p of potentialParticipants) {
const key = `${p.name}|${p.surname}|${p.email}`.toLowerCase(); // Create a unique key
participantCounts.set(key, (participantCounts.get(key) || 0) + 1);
}
// Create final arrays, modifying duplicate surnames to be unique
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
const processedParticipants = new Map<string, number>();
for (const p of potentialParticipants) {
const key = `${p.name}|${p.surname}|${p.email}`.toLowerCase();
let finalSurname = p.surname;
// If this participant is a duplicate
if (participantCounts.get(key)! > 1) {
const count = (processedParticipants.get(key) || 0) + 1;
processedParticipants.set(key, count);
// If it's not the first occurrence, append a counter to the surname
if (count > 1) {
finalSurname = `${p.surname} (${count})`;
}
}
names.push(p.name);
surnames.push(finalSurname);
emails.push(p.email); // Keep the original email
}
// --- End of new logic ---
// Call database function to add participants
const { error: syncError } = await data.supabase.rpc('participants_add_bulk', {
p_event: eventId,
@@ -178,15 +209,22 @@
// Reload participants
await loadParticipants();
// Show success message with count of synced participants
const previousCount = participants.length;
const newCount = names.length;
const addedCount = Math.max(0, participants.length - previousCount);
// Show success message with accurate count of changes
const newCount = participants.length;
const diff = newCount - previousCount;
const processedCount = names.length;
toast.success(
`Successfully synced participants. ${newCount} entries processed, ${addedCount} new participants added.`,
5000
);
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);
toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);

View File

@@ -81,7 +81,7 @@
</button>
<button
onclick={toggleEdit}
class="rounded bg-gray-300 px-4 py-2 text-gray-700 transition hover:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-50"
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"
>
@@ -123,7 +123,7 @@
type="text"
bind:value={emailSubject}
disabled={!isEditing || updatingEmail}
class="w-full px-3 py-2 border border-gray-300 rounded-lg {!isEditing ? 'bg-gray-50 cursor-default' : ''} focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50"
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>
@@ -134,7 +134,7 @@
bind:value={emailBody}
rows="6"
disabled={!isEditing || updatingEmail}
class="w-full px-3 py-2 border border-gray-300 rounded-lg {!isEditing ? 'bg-gray-50 cursor-default' : ''} focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50"
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">

View File

@@ -35,7 +35,7 @@
function handleSyncParticipants() {
// Show initial notification about sync starting
toast.info('Starting participant synchronization...', 2000);
toast.info('Starting participant synchronization...', 5000);
// Call the parent component's sync function
onSyncParticipants();
@@ -46,7 +46,7 @@
<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

@@ -2,10 +2,9 @@
let { data } = $props();
</script>
<div class="p-4 sm:p-6">
<h1 class="mb-6 text-2xl font-bold text-gray-800">User Dashboard</h1>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">User Dashboard</h1>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<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">
@@ -21,8 +20,7 @@
<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
class="text-sm text-red-500 transition hover:text-red-700 hover:underline">Sign Out</a
>
</div>
</div>
@@ -53,10 +51,10 @@
<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.
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>
@@ -65,12 +63,11 @@
<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.
As an Events Manager, you have access to the <strong>Events</strong> section. Here you can
create new events, manage participants by syncing with Google Sheets, send email invitations
with QR codes, and view event statistics.
</p>
</div>
{/if}
</div>
</div>
</div>

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

@@ -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,86 +1,71 @@
/// <reference lib="webworker" />
/// <reference types="@sveltejs/kit" />
import { build, files, version } from '$service-worker';
// Create a unique cache name for this deployment
const CACHE = `cache-${version}`;
declare const self: ServiceWorkerGlobalScope;
const CACHE = `cache-${version}`;
const ASSETS = [
...build, // the app itself
...files // everything in `static`
...build,
...files
];
self.addEventListener('install', (event) => {
// Create a new cache and add all files to it
async function addFilesToCache() {
self.addEventListener('install', (event: ExtendableEvent) => {
const addFilesToCache = async () => {
const cache = await caches.open(CACHE);
await cache.addAll(ASSETS);
}
};
console.log("[SW] Installing new service worker");
event.waitUntil(addFilesToCache());
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
// Remove previous cached data from disk
async function deleteOldCaches() {
self.addEventListener('activate', (event: ExtendableEvent) => {
const deleteOldCaches = async () => {
for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key);
console.log("[SW] Removing old service worker")
}
}
};
event.waitUntil(deleteOldCaches());
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
// ignore POST requests etc
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.method !== 'GET') return;
async function respond() {
const url = new URL(event.request.url);
// Skip caching for auth routes
if (url.pathname.startsWith('/auth/')) {
return fetch(event.request);
// Never cache private routes
if (url.pathname.startsWith('/private')) {
event.respondWith(fetch(event.request));
return;
}
const respond = async () => {
const cache = await caches.open(CACHE);
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
const response = await cache.match(url.pathname);
if (response) {
return response;
}
const cached = await cache.match(url.pathname);
if (cached) return cached;
}
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
// if we're offline, fetch can return a value that is not a Response
// instead of throwing - and we can't pass this non-Response to respondWith
if (!(response instanceof Response)) {
throw new Error('invalid response from fetch');
}
if (response.status === 200) {
if (response.status === 200 && build.length > 0 && url.pathname.startsWith(`/${build[0]}/`)) {
cache.put(event.request, response.clone());
}
return response;
} catch (err) {
const response = await cache.match(event.request);
if (response) {
return response;
} catch {
const cached = await cache.match(event.request);
if (cached) return cached;
}
// if there's no cache, then just error out
// as there is nothing we can do to respond to this request
throw err;
}
}
return new Response('Not found', { status: 404 });
};
event.respondWith(respond());
});

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.