First stage of the new flow

This commit is contained in:
Roman Krček
2025-07-02 21:50:45 +02:00
parent 5fd647d894
commit 822f1a7342
15 changed files with 1317 additions and 140 deletions

View File

@@ -1,2 +1,7 @@
PUBLIC_SUPABASE_URL=https://abc.supabase.co PUBLIC_SUPABASE_URL=https://abc.supabase.co
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s
# Google OAuth Configuration
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_REDIRECT_URI=http://localhost:5173

View File

@@ -85,4 +85,85 @@ NEVER $: label syntax; use $state(), $derived(), and $effect().
If you want to use supabse client in the browser, it is stored in the data If you want to use supabse client in the browser, it is stored in the data
variable obtained from let { data } = $props(); variable obtained from let { data } = $props();
Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead
onsubmit|preventDefault={handleSubmit} is depracated, do not use it!
Loading session using page.server.ts is not needed as the session is already available in the locals object.
The database schema in supabase is as follows:
-- WARNING: This schema is for context only and is not meant to be run.
-- Table order and constraints may not be valid for execution.
CREATE TABLE public.events (
id uuid NOT NULL DEFAULT gen_random_uuid(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
created_by uuid DEFAULT auth.uid(),
name text,
date date,
section_id uuid,
email_subject text,
email_body text,
sheet_id text,
name_column numeric,
surname_column numeric,
email_column numeric,
confirmation_column numeric,
CONSTRAINT events_pkey PRIMARY KEY (id),
CONSTRAINT events_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id),
CONSTRAINT events_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id)
);
CREATE TABLE public.events_archived (
id uuid NOT NULL DEFAULT gen_random_uuid(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
date date,
name text NOT NULL,
total_participants numeric,
scanned_participants numeric,
section_id uuid,
CONSTRAINT events_archived_pkey PRIMARY KEY (id),
CONSTRAINT events_archived_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id)
);
CREATE TABLE public.participants (
id uuid NOT NULL DEFAULT gen_random_uuid(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
created_by uuid DEFAULT auth.uid(),
event uuid,
name text,
surname text,
email text,
scanned boolean DEFAULT false,
scanned_at timestamp with time zone,
scanned_by uuid,
section_id uuid,
CONSTRAINT participants_pkey PRIMARY KEY (id),
CONSTRAINT participants_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id),
CONSTRAINT participants_event_fkey FOREIGN KEY (event) REFERENCES public.events(id),
CONSTRAINT participants_scanned_by_fkey FOREIGN KEY (scanned_by) REFERENCES public.profiles(id),
CONSTRAINT qrcodes_scanned_by_fkey FOREIGN KEY (scanned_by) REFERENCES auth.users(id),
CONSTRAINT qrcodes_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id)
);
CREATE TABLE public.profiles (
id uuid NOT NULL,
display_name text,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
section_id uuid,
section_position USER-DEFINED NOT NULL DEFAULT 'member'::section_posititon,
CONSTRAINT profiles_pkey PRIMARY KEY (id),
CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id),
CONSTRAINT profiles_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id)
);
CREATE TABLE public.sections (
id uuid NOT NULL DEFAULT gen_random_uuid(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
name text NOT NULL UNIQUE,
CONSTRAINT sections_pkey PRIMARY KEY (id)
);
An event is created by calling RPC databse function create_event
by passing the following parameters:
- name, date, email_subject, email_body, sheet_id, name_column, surname_column, email_column, confirmation_column

80
src/lib/gmail.ts Normal file
View File

@@ -0,0 +1,80 @@
import { google } from 'googleapis';
import quotedPrintable from 'quoted-printable';
import { getAuthenticatedClient } from './google-server.js';
export function createEmailTemplate(text: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<style>
@import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
</style>
</head>
<body style="font-family: 'Lato', sans-serif; background-color: #f9f9f9; padding: 20px; margin: 0;">
<div style="max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
<p style="white-space: pre-line;font-size: 16px; line-height: 1.5; color: #333;">${text}</p>
<img src="cid:qrCode1" alt="QR Code" style="display: block; margin: 20px auto; max-width: 50%; min-width: 200px; height: auto;" />
<div style="width: 100%; display: flex; flex-direction: row; justify-content: space-between">
<div style="height: 4px; width: 20%; background: #00aeef;"></div>
<div style="height: 4px; width: 20%; background: #ec008c;"></div>
<div style="height: 4px; width: 20%; background: #7ac143;"></div>
<div style="height: 4px; width: 20%; background: #f47b20;"></div>
<div style="height: 4px; width: 20%; background: #2e3192;"></div>
</div>
<div style="font-size: 12px; color: #999; padding-top: 0px; margin-top: 10px; line-height: 1.5; ">
<p>This email has been generated with the help of ScanWave</p>
</div>
</div>
</body>
</html>`;
}
export async function sendGmail(
refreshToken: string,
{ to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string }
) {
const oauth = getAuthenticatedClient(refreshToken);
const gmail = google.gmail({ version: 'v1', auth: oauth });
const message_html = createEmailTemplate(text);
const boundary = 'BOUNDARY';
const nl = '\r\n';
// Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode
const htmlBuffer = Buffer.from(message_html, 'utf8');
const htmlLatin1 = htmlBuffer.toString('latin1');
const htmlQP = quotedPrintable.encode(htmlLatin1);
const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl);
const rawParts = [
'MIME-Version: 1.0',
`To: ${to}`,
`Subject: ${subject}`,
`Content-Type: multipart/related; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'Content-Transfer-Encoding: quoted-printable',
'',
htmlQP,
'',
`--${boundary}`,
'Content-Type: image/png',
'Content-Transfer-Encoding: base64',
'Content-ID: <qrCode1>',
'Content-Disposition: inline; filename="qr.png"',
'',
qrLines,
'',
`--${boundary}--`,
''
];
const rawMessage = rawParts.join(nl);
const raw = Buffer.from(rawMessage).toString('base64url');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw }
});
}

38
src/lib/google-server.ts Normal file
View File

@@ -0,0 +1,38 @@
import { google } from 'googleapis';
import { env } from '$env/dynamic/private';
export const scopes = [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/spreadsheets.readonly'
];
export function getOAuthClient() {
return new google.auth.OAuth2(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
env.GOOGLE_REDIRECT_URI
);
}
export function createAuthUrl() {
return getOAuthClient().generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: scopes,
redirect_uri: env.GOOGLE_REDIRECT_URI
});
}
export async function exchangeCodeForTokens(code: string) {
const { tokens } = await getOAuthClient().getToken(code);
if (!tokens.refresh_token) throw new Error('No refresh_token returned');
return tokens.refresh_token;
}
export function getAuthenticatedClient(refreshToken: string) {
const oauth = getOAuthClient();
oauth.setCredentials({ refresh_token: refreshToken });
return oauth;
}

View File

@@ -1,108 +1,60 @@
import { google } from 'googleapis'; import { browser } from '$app/environment';
import { env } from '$env/dynamic/private';
import quotedPrintable from 'quoted-printable'; // tiny, zero-dep package
// Client-side only functions
export const scopes = [ export const scopes = [
'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/userinfo.email' 'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/spreadsheets.readonly'
]; ];
export function getOAuthClient() { // Client-side functions for browser environment
return new google.auth.OAuth2( export async function initGoogleAuth(): Promise<void> {
env.GOOGLE_CLIENT_ID, if (!browser) return;
env.GOOGLE_CLIENT_SECRET, // Google Auth initialization is handled by the OAuth flow
env.GOOGLE_REDIRECT_URI // No initialization needed for our server-side approach
);
} }
export function createAuthUrl() { export function getAuthUrl(): string {
return getOAuthClient().generateAuthUrl({ if (!browser) return '';
access_type: 'offline', // This should be obtained from the server
prompt: 'consent', return '/auth/google';
scope: scopes
});
} }
export async function exchangeCodeForTokens(code: string) { export async function isTokenValid(accessToken: string): Promise<boolean> {
const { tokens } = await getOAuthClient().getToken(code); if (!browser) return false;
if (!tokens.refresh_token) throw new Error('No refresh_token returned');
return tokens.refresh_token; try {
const response = await fetch(`https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${accessToken}`);
const data = await response.json();
if (response.ok && data.expires_in && data.expires_in > 0) {
return true;
}
return false;
} catch (error) {
console.error('Error validating token:', error);
return false;
}
} }
export async function sendGmail( export async function refreshAccessToken(refreshToken: string): Promise<string | null> {
refreshToken: string, try {
{ to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string } const response = await fetch('/api/auth/refresh', {
) { method: 'POST',
const oauth = getOAuthClient(); headers: {
oauth.setCredentials({ refresh_token: refreshToken }); 'Content-Type': 'application/json'
},
const gmail = google.gmail({ version: 'v1', auth: oauth }); body: JSON.stringify({ refreshToken })
});
const message_html =
`<!DOCTYPE html> if (response.ok) {
<html lang="en"> const data = await response.json();
<head> return data.accessToken;
<style> }
@import url('https://fonts.googleapis.com/css2?family=Lato&display=swap'); return null;
</style> } catch (error) {
</head> console.error('Error refreshing token:', error);
<body style="font-family: 'Lato', sans-serif; background-color: #f9f9f9; padding: 20px; margin: 0;"> return null;
<div style="max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);"> }
<p style="white-space: pre-line;font-size: 16px; line-height: 1.5; color: #333;">${text}</p>
<img src="cid:qrCode1" alt="QR Code" style="display: block; margin: 20px auto; max-width: 50%; min-width: 200px; height: auto;" />
<div style="width: 100%; display: flex; flex-direction: row; justify-content: space-between">
<div style="height: 4px; width: 20%; background: #00aeef;"></div>
<div style="height: 4px; width: 20%; background: #ec008c;"></div>
<div style="height: 4px; width: 20%; background: #7ac143;"></div>
<div style="height: 4px; width: 20%; background: #f47b20;"></div>
<div style="height: 4px; width: 20%; background: #2e3192;"></div>
</div>
<div style="font-size: 12px; color: #999; padding-top: 0px; margin-top: 10px; line-height: 1.5; ">
<p>This email has been generated with the help of ScanWave</p>
</div>
</div>
</body>
</html>`;
const boundary = 'BOUNDARY';
const nl = '\r\n'; // RFC-5322 line ending
// Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode
const htmlBuffer = Buffer.from(message_html, 'utf8');
const htmlLatin1 = htmlBuffer.toString('latin1');
const htmlQP = quotedPrintable.encode(htmlLatin1);
const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl);
const rawParts = [
'MIME-Version: 1.0',
`To: ${to}`,
`Subject: ${subject}`,
`Content-Type: multipart/related; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'Content-Transfer-Encoding: quoted-printable',
'',
htmlQP,
'',
`--${boundary}`,
'Content-Type: image/png',
'Content-Transfer-Encoding: base64',
'Content-ID: <qrCode1>',
'Content-Disposition: inline; filename="qr.png"',
'',
qrLines,
'',
`--${boundary}--`,
''
];
const rawMessage = rawParts.join(nl);
const raw = Buffer.from(rawMessage).toString('base64url');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw }
});
} }

58
src/lib/sheets.ts Normal file
View File

@@ -0,0 +1,58 @@
import { google } from 'googleapis';
import { getAuthenticatedClient } from './google-server.js';
export interface GoogleSheet {
id: string;
name: string;
modifiedTime: string;
webViewLink: string;
}
export interface SheetData {
values: string[][];
}
export async function getRecentSpreadsheets(refreshToken: string, limit: number = 10): Promise<GoogleSheet[]> {
const oauth = getAuthenticatedClient(refreshToken);
const drive = google.drive({ version: 'v3', auth: oauth });
const response = await drive.files.list({
q: "mimeType='application/vnd.google-apps.spreadsheet'",
orderBy: 'modifiedTime desc',
pageSize: limit,
fields: 'files(id,name,modifiedTime,webViewLink)'
});
return response.data.files?.map(file => ({
id: file.id!,
name: file.name!,
modifiedTime: file.modifiedTime!,
webViewLink: file.webViewLink!
})) || [];
}
export async function getSpreadsheetData(refreshToken: string, spreadsheetId: string, range: string = 'A1:Z10'): Promise<SheetData> {
const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth });
const response = await sheets.spreadsheets.values.get({
spreadsheetId,
range
});
return {
values: response.data.values || []
};
}
export async function getSpreadsheetInfo(refreshToken: string, spreadsheetId: string) {
const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth });
const response = await sheets.spreadsheets.get({
spreadsheetId,
fields: 'properties.title,sheets.properties(title,sheetId)'
});
return response.data;
}

View File

@@ -0,0 +1,30 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getOAuthClient } from '$lib/google-server.js';
export const POST: RequestHandler = async ({ request }) => {
try {
const { refreshToken } = await request.json();
if (!refreshToken) {
return json({ error: 'Refresh token is required' }, { status: 400 });
}
const oauth = getOAuthClient();
oauth.setCredentials({ refresh_token: refreshToken });
const { credentials } = await oauth.refreshAccessToken();
if (!credentials.access_token) {
return json({ error: 'Failed to refresh token' }, { status: 500 });
}
return json({
accessToken: credentials.access_token,
expiresIn: credentials.expiry_date
});
} catch (error) {
console.error('Error refreshing access token:', error);
return json({ error: 'Failed to refresh access token' }, { status: 500 });
}
};

View File

@@ -0,0 +1,22 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSpreadsheetData } from '$lib/sheets.js';
export const GET: RequestHandler = async ({ params, request }) => {
try {
const { sheetId } = params;
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return json({ error: 'Missing or invalid authorization header' }, { status: 401 });
}
const refreshToken = authHeader.slice(7);
const sheetData = await getSpreadsheetData(refreshToken, sheetId, 'A1:Z10');
return json(sheetData);
} catch (error) {
console.error('Error fetching spreadsheet data:', error);
return json({ error: 'Failed to fetch spreadsheet data' }, { status: 500 });
}
};

View File

@@ -0,0 +1,20 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRecentSpreadsheets } from '$lib/sheets.js';
export const GET: RequestHandler = async ({ request }) => {
try {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return json({ error: 'Missing or invalid authorization header' }, { status: 401 });
}
const refreshToken = authHeader.slice(7);
const sheets = await getRecentSpreadsheets(refreshToken, 20);
return json(sheets);
} catch (error) {
console.error('Error fetching recent spreadsheets:', error);
return json({ error: 'Failed to fetch spreadsheets' }, { status: 500 });
}
};

View File

@@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createAuthUrl } from '$lib/google-server.js';
export const GET: RequestHandler = () => {
const authUrl = createAuthUrl();
throw redirect(302, authUrl);
};

View File

@@ -0,0 +1,110 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getOAuthClient } from '$lib/google-server.js';
export const GET: RequestHandler = async ({ url }) => {
try {
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
console.error('Google OAuth error:', error);
throw redirect(302, '/private/events?error=google_auth_denied');
}
if (!code) {
throw redirect(302, '/private/events?error=missing_auth_code');
}
// Exchange code for tokens
const oauth = getOAuthClient();
const { tokens } = await oauth.getToken(code);
if (!tokens.refresh_token || !tokens.access_token) {
throw redirect(302, '/private/events?error=incomplete_tokens');
}
// Create a success page with tokens that closes the popup and communicates with parent
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Google Authentication Success</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: #f9fafb;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.success {
color: #059669;
margin-bottom: 1rem;
}
.loading {
color: #6b7280;
}
</style>
</head>
<body>
<div class="container">
<div class="success">✓ Authentication successful!</div>
<div class="loading">Closing window...</div>
</div>
<script>
(function() {
try {
// Store tokens in the parent window's localStorage
if (window.opener && !window.opener.closed) {
window.opener.localStorage.setItem('google_access_token', '${tokens.access_token}');
window.opener.localStorage.setItem('google_refresh_token', '${tokens.refresh_token}');
// Send success message to parent
window.opener.postMessage({
type: 'GOOGLE_AUTH_SUCCESS',
tokens: {
accessToken: '${tokens.access_token}',
refreshToken: '${tokens.refresh_token}'
}
}, '*');
// Close the popup after a short delay to ensure message is received
setTimeout(() => {
window.close();
}, 500);
} else {
// If no opener, close immediately
window.close();
}
} catch (error) {
console.error('Error in auth callback:', error);
// Try to close the window anyway
setTimeout(() => {
window.close();
}, 1000);
}
})();
</script>
</body>
</html>`;
return new Response(html, {
headers: {
'Content-Type': 'text/html'
}
});
} catch (error) {
console.error('Error handling Google OAuth callback:', error);
throw redirect(302, '/private/events?error=google_auth_failed');
}
};

View File

@@ -3,9 +3,9 @@ import { json, redirect } from '@sveltejs/kit';
import { import {
createAuthUrl, createAuthUrl,
exchangeCodeForTokens, exchangeCodeForTokens,
sendGmail,
getOAuthClient getOAuthClient
} from '$lib/google'; } from '$lib/google';
import { sendGmail } from '$lib/gmail';
/* ───────────── GET ───────────── */ /* ───────────── GET ───────────── */
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {

View File

@@ -1,53 +1,24 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; export let data;
import SingleEvent from './SingleEvent.svelte';
let { data } = $props();
let events: any[] = $state([]);
let archived_events: any[] = $state([]);
let loading = $state(true);
onMount(async () => {
const { data: evs } = await data.supabase
.from('events')
.select('id, name, date')
.order('date', { ascending: false });
const { data: aevs } = await data.supabase
.from('events_archived')
.select('id, name, date')
.order('date', { ascending: false });
events = evs || [];
archived_events = aevs || [];
loading = false;
});
</script> </script>
<h1 class="text-2xl font-bold mb-4 mt-2 text-center">All Events</h1> <h1 class="text-2xl font-bold mb-4 mt-2 text-center">All Events</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
{#if loading} {#each data.events as event}
{#each Array(4) as _} <a
<div class="block border border-gray-300 rounded bg-white p-4 shadow-none min-h-[72px] h-full w-full"> href={`/private/events/event?id=${event.id}`}
<div class="flex flex-col gap-1"> class="block border border-gray-300 rounded bg-white p-4 shadow-none transition cursor-pointer hover:border-blue-500 group"
<span class="font-semibold text-lg text-gray-300 bg-gray-200 rounded w-1/2 h-6 mb-2 inline-block"></span> >
<span class="text-gray-300 text-sm bg-gray-100 rounded w-1/3 h-4 inline-block"></span> <div class="flex flex-col gap-1">
</div> <span class="font-semibold text-lg text-black-700 group-hover:underline">{event.name}</span>
<span class="text-gray-500 text-sm">{event.date}</span>
</div> </div>
{/each} </a>
{:else} {/each}
{#each events as event}
<SingleEvent id={event.id} name={event.name} date={event.date} archived={false} />
{/each}
{#each archived_events as event}
<SingleEvent id={event.id} name={event.name} date={event.date} archived={true} />
{/each}
{/if}
</div> </div>
<a <a
href="/private/events/creator" href="/private/events/event/new"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-full shadow-none border border-gray-300" class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-full shadow-none border border-gray-300"
> >
New Event New Event

View File

@@ -0,0 +1,7 @@
export const load = async ({ locals }: { locals: any }) => {
const { session } = await locals.safeGetSession();
return {
session
};
};

View File

@@ -0,0 +1,795 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { GoogleSheet } from '$lib/sheets.js';
import { isTokenValid, refreshAccessToken } from '$lib/google.js';
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
// 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
});
// Step 1: Event Details
let eventData = $state({
name: '',
date: ''
});
// Step 2: Google Sheets
let sheetsData = $state({
availableSheets: [] as GoogleSheet[],
selectedSheet: null as GoogleSheet | null,
sheetData: [] as string[][],
columnMapping: {
name: 0, // Initialize to 0 (no column selected)
surname: 0,
email: 0,
confirmation: 0
},
loading: false,
expandedSheetList: true // Add this flag to control sheet list expansion
});
// Step 3: Email
let emailData = $state({
subject: '',
body: ''
});
// General state
let loading = $state(false);
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;
} else {
authData.isConnected = false;
}
} catch (error) {
console.error('Error checking Google auth:', error);
authData.isConnected = false;
authData.error = 'Error checking Google connection';
} 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;
// Listen for messages from the popup
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === 'GOOGLE_AUTH_SUCCESS') {
authCompleted = true;
authData.connecting = false;
authData.showCancelOption = false;
window.removeEventListener('message', messageHandler);
// Clean up timers
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
// Check auth status again after success
setTimeout(checkGoogleAuth, 100);
}
};
// Clean up function to handle all cleanup in one place
const cleanUp = () => {
window.removeEventListener('message', messageHandler);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
authData.connecting = false;
};
window.addEventListener('message', messageHandler);
// Set a timeout to check auth status regardless of popup state
// This is a workaround for Cross-Origin-Opener-Policy restrictions
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);
}
}, 60 * 1000) as unknown as number;
// After 20 seconds with no response, show cancel option
cancelTimeout = setTimeout(() => {
if (!authCompleted) {
authData.showCancelOption = true;
}
}, 20 * 1000) as unknown as number;
// Set a final timeout to clean up everything if nothing else worked
setTimeout(() => {
if (!authCompleted) {
cleanUp();
}
}, 3 * 60 * 1000); // 3 minute max timeout
} 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;
}
// Step navigation
function nextStep() {
if (validateCurrentStep()) {
currentStep = Math.min(currentStep + 1, totalSteps - 1);
if (currentStep === 2) {
loadRecentSheets();
}
}
}
function prevStep() {
currentStep = Math.max(currentStep - 1, 0);
}
function validateCurrentStep(): boolean {
errors = {};
if (currentStep === 0) {
if (!authData.isConnected) {
errors.auth = 'Please connect your Google account to continue';
return false;
}
} else if (currentStep === 1) {
if (!eventData.name.trim()) {
errors.name = 'Event name is required';
}
if (!eventData.date) {
errors.date = 'Event date is required';
}
} else if (currentStep === 2) {
if (!sheetsData.selectedSheet) {
errors.sheet = 'Please select a Google Sheet';
}
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(', ')}`;
}
}
} else if (currentStep === 3) {
if (!emailData.subject.trim()) {
errors.subject = 'Email subject is required';
}
if (!emailData.body.trim()) {
errors.body = 'Email body is required';
}
}
return Object.keys(errors).length === 0;
}
// Google Sheets functions
async function loadRecentSheets() {
sheetsData.loading = true;
// Always expand the sheet list when loading new sheets
sheetsData.expandedSheetList = true;
try {
// TODO: Replace with actual API call
const response = await fetch('/api/sheets/recent', {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}`
}
});
if (response.ok) {
sheetsData.availableSheets = await response.json();
}
} catch (error) {
console.error('Error loading sheets:', error);
errors.sheets = 'Failed to load Google Sheets';
} finally {
sheetsData.loading = false;
}
}
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 {
// TODO: Replace with actual API call
const response = await fetch(`/api/sheets/${sheet.id}/data`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}`
}
});
if (response.ok) {
const data = await response.json();
sheetsData.sheetData = data.values || [];
}
} catch (error) {
console.error('Error loading sheet data:', error);
errors.sheetData = 'Failed to load sheet data';
} finally {
sheetsData.loading = false;
}
}
// Toggle the sheet list expansion
function toggleSheetList() {
sheetsData.expandedSheetList = !sheetsData.expandedSheetList;
}
// Final submission
async function createEvent() {
if (!validateCurrentStep()) return;
loading = true;
try {
// TODO: Replace with actual Supabase function call
const { error } = await data.supabase.rpc('create_event', {
p_name: eventData.name,
p_date: eventData.date,
p_email_subject: emailData.subject,
p_email_body: emailData.body,
p_sheet_id: sheetsData.selectedSheet?.id,
p_name_column: sheetsData.columnMapping.name,
p_surname_column: sheetsData.columnMapping.surname,
p_email_column: sheetsData.columnMapping.email,
p_confirmation_column: sheetsData.columnMapping.confirmation
});
if (error) throw error;
// Redirect to events list or show success message
window.location.href = '/private/events';
} catch (error) {
console.error('Error creating event:', error);
errors.submit = 'Failed to create event. Please try again.';
} finally {
loading = false;
}
}
// Computed values
let canProceed = $derived(() => {
if (currentStep === 0) return authData.isConnected;
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;
}
if (currentStep === 3) return emailData.subject && emailData.body;
return false;
});
let stepTitle = $derived(() => {
if (currentStep === 0) return 'Connect Google Account';
if (currentStep === 1) return 'Event Details';
if (currentStep === 2) return 'Connect Google Sheets';
if (currentStep === 3) return 'Email Settings';
return '';
});
</script>
<div class="max-w-4xl mx-auto p-6">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Create New Event</h1>
<div class="flex items-center gap-4">
{#each Array(totalSteps) as _, index}
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium {
index === currentStep ? 'bg-blue-600 text-white' :
index < currentStep ? 'bg-green-600 text-white' :
'bg-gray-200 text-gray-600'
}">
{index + 1}
</div>
{#if index < totalSteps - 1}
<div class="w-8 h-1 {
index < currentStep ? 'bg-green-600' : 'bg-gray-200'
}"></div>
{/if}
</div>
{/each}
</div>
<p class="text-gray-600 mt-2">Step {currentStep + 1} of {totalSteps}: {stepTitle}</p>
</div>
<!-- Step Content -->
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-6">
{#if currentStep === 0}
<!-- Step 0: Google Authentication -->
<div class="space-y-6">
<div class="text-center">
<h3 class="text-lg font-medium text-gray-900 mb-4">Connect Your Google Account</h3>
<p class="text-gray-600 mb-6">
To create events and import participants from Google Sheets, you need to connect your Google account.
</p>
{#if authData.checking}
<div class="flex justify-center items-center space-x-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span class="text-gray-600">Checking connection...</span>
</div>
{:else if authData.isConnected}
<div class="rounded-lg bg-green-50 border border-green-200 p-4 mb-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">
Google account connected successfully!
</p>
<p class="text-sm text-green-700 mt-1">
You can now access Google Sheets and Gmail features.
</p>
</div>
</div>
</div>
{:else}
<div class="rounded-lg bg-yellow-50 border border-yellow-200 p-4 mb-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-yellow-800">
Google account not connected
</p>
<p class="text-sm text-yellow-700 mt-1">
Please connect your Google account to continue with event creation.
</p>
</div>
</div>
</div>
<div class="flex flex-col gap-3">
<button
onclick={connectToGoogle}
disabled={authData.connecting}
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Connect to Google account"
>
{#if authData.connecting}
<div class="w-5 h-5 mr-2 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
Connecting...
{:else}
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Connect to Google
{/if}
</button>
{#if authData.connecting && authData.showCancelOption}
<button
onclick={cancelGoogleAuth}
class="text-sm text-gray-600 hover:text-gray-900"
aria-label="Cancel Google authentication"
>
Cancel connection
</button>
<p class="text-xs text-gray-500 mt-1">
Taking too long? You can cancel and try again.
</p>
{/if}
</div>
{/if}
{#if authData.error}
<div class="mt-4 rounded-lg bg-red-50 border border-red-200 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800">
Connection Error
</p>
<p class="text-sm text-red-700 mt-1">
{authData.error}
</p>
</div>
</div>
</div>
{/if}
{#if errors.auth}
<p class="mt-2 text-sm text-red-600">{errors.auth}</p>
{/if}
</div>
</div>
{:else if currentStep === 1}
<!-- Step 1: Event Details -->
<div class="space-y-6">
<div>
<label for="eventName" class="block text-sm font-medium text-gray-700 mb-2">
Event Name *
</label>
<input
id="eventName"
type="text"
bind:value={eventData.name}
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>
<label for="eventDate" class="block text-sm font-medium text-gray-700 mb-2">
Event Date *
</label>
<input
id="eventDate"
type="date"
bind:value={eventData.date}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{#if errors.date}
<p class="mt-1 text-sm text-red-600">{errors.date}</p>
{/if}
</div>
</div>
{:else if currentStep === 2}
<!-- Step 2: Google Sheets -->
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Select Google Sheet</h3>
{#if sheetsData.loading && sheetsData.availableSheets.length === 0}
<div class="space-y-3">
{#each Array(5) as _}
<div class="p-4 border border-gray-200 rounded animate-pulse">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-3 bg-gray-100 rounded w-1/2"></div>
</div>
{/each}
</div>
{:else if sheetsData.availableSheets.length === 0}
<div class="text-center py-8">
<p class="text-gray-500">No Google Sheets found.</p>
<button
onclick={loadRecentSheets}
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
>
Refresh
</button>
</div>
{:else}
<div class="space-y-3">
{#if !sheetsData.expandedSheetList && sheetsData.selectedSheet}
<!-- Selected sheet only (collapsed view) -->
<div class="flex items-center justify-between p-4 border border-blue-500 bg-blue-50 rounded">
<div>
<div class="font-medium text-gray-900">{sheetsData.selectedSheet.name}</div>
<div class="text-sm text-gray-500">
Modified: {new Date(sheetsData.selectedSheet.modifiedTime).toLocaleDateString()}
</div>
</div>
<button
onclick={toggleSheetList}
class="text-blue-600 hover:text-blue-800 flex items-center"
aria-label="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" />
</svg>
</button>
</div>
{:else}
<!-- All sheets (expanded view) -->
<div class="flex justify-between items-center mb-2">
<h4 class="text-sm font-medium text-gray-700">Available Sheets</h4>
{#if sheetsData.selectedSheet}
<button
onclick={toggleSheetList}
class="text-sm text-blue-600 hover:text-blue-800"
aria-label="Hide sheet list"
>
Collapse list
</button>
{/if}
</div>
<div class="grid gap-3">
{#each sheetsData.availableSheets as sheet}
<button
onclick={() => selectSheet(sheet)}
class="p-4 text-left border border-gray-200 rounded hover:border-blue-500 transition {
sheetsData.selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : ''
}"
>
<div class="font-medium text-gray-900">{sheet.name}</div>
<div class="text-sm text-gray-500">
Modified: {new Date(sheet.modifiedTime).toLocaleDateString()}
</div>
</button>
{/each}
</div>
{/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}
<div>
<h4 class="text-md font-medium text-gray-900 mb-3">Column Mapping</h4>
<!-- Instructions for column mapping -->
<div class="bg-blue-50 p-4 rounded-md border border-blue-200 mb-4">
<p class="text-sm text-blue-800 mb-2 font-medium">Column Mapping Instructions:</p>
<p class="text-sm text-blue-700">
Select what each column represents by using the dropdown in each column header.
Make sure to assign Name, Surname, Email, and Confirmation columns.
</p>
</div>
<div class="overflow-x-auto">
<table class="w-full border border-gray-200 rounded">
<thead>
<tr class="bg-gray-50">
{#each sheetsData.sheetData[0] || [] as header, index}
<th class="px-3 py-2 border-b border-gray-200 text-left">
<div class="flex flex-col gap-2">
<div class="font-medium text-gray-900">
Column {index + 1}
<span class="text-xs text-gray-500">({header || 'Empty'})</span>
</div>
<select
class="text-sm normal-case font-normal px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
aria-label={`Select data type for column ${index + 1}`}
onclick={(e) => e.stopPropagation()}
onchange={(e) => {
const value = e.target.value;
if (value === "none") return;
// Reset previous selection if this column was already mapped
if (sheetsData.columnMapping.name === index + 1) sheetsData.columnMapping.name = 0;
if (sheetsData.columnMapping.surname === index + 1) sheetsData.columnMapping.surname = 0;
if (sheetsData.columnMapping.email === index + 1) sheetsData.columnMapping.email = 0;
if (sheetsData.columnMapping.confirmation === index + 1) sheetsData.columnMapping.confirmation = 0;
// Set new mapping
if (value === "name") sheetsData.columnMapping.name = index + 1;
else if (value === "surname") sheetsData.columnMapping.surname = index + 1;
else if (value === "email") sheetsData.columnMapping.email = index + 1;
else if (value === "confirmation") sheetsData.columnMapping.confirmation = index + 1;
}}
>
<option value="none">Select data type</option>
<option value="name" selected={sheetsData.columnMapping.name === index + 1}>Name</option>
<option value="surname" selected={sheetsData.columnMapping.surname === index + 1}>Surname</option>
<option value="email" selected={sheetsData.columnMapping.email === index + 1}>Email</option>
<option value="confirmation" selected={sheetsData.columnMapping.confirmation === index + 1}>Confirmation</option>
</select>
{#if sheetsData.columnMapping.name === index + 1}
<span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">Name Column</span>
{:else if sheetsData.columnMapping.surname === index + 1}
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">Surname Column</span>
{:else if sheetsData.columnMapping.email === index + 1}
<span class="bg-purple-100 text-purple-800 text-xs px-2 py-1 rounded">Email Column</span>
{:else if sheetsData.columnMapping.confirmation === index + 1}
<span class="bg-amber-100 text-amber-800 text-xs px-2 py-1 rounded">Confirmation Column</span>
{/if}
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sheetsData.sheetData.slice(0, 10) as row, rowIndex}
<tr class="hover:bg-gray-50">
{#each row as cell, cellIndex}
<td class="px-3 py-2 border-b border-gray-100 text-sm">
<span
class={
sheetsData.columnMapping.name === cellIndex + 1 ? 'font-medium text-green-700' :
sheetsData.columnMapping.surname === cellIndex + 1 ? 'font-medium text-blue-700' :
sheetsData.columnMapping.email === cellIndex + 1 ? 'font-medium text-purple-700' :
sheetsData.columnMapping.confirmation === cellIndex + 1 ? 'font-medium text-amber-700' :
'text-gray-700'
}
>
{cell || ''}
</span>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="mt-2 text-sm text-gray-500">Showing first 10 rows</p>
</div>
{/if}
{#if sheetsData.loading && sheetsData.selectedSheet}
<div class="text-center py-4">
<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>
{:else if currentStep === 3}
<!-- Step 3: Email Settings -->
<div class="space-y-6">
<div>
<label for="emailSubject" class="block text-sm font-medium text-gray-700 mb-2">
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"
placeholder="Event invitation subject"
/>
{#if errors.subject}
<p class="mt-1 text-sm text-red-600">{errors.subject}</p>
{/if}
</div>
<div>
<label for="emailBody" class="block text-sm font-medium text-gray-700 mb-2">
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"
placeholder="Email message content..."
></textarea>
{#if errors.body}
<p class="mt-1 text-sm text-red-600">{errors.body}</p>
{/if}
</div>
<!-- Preview -->
<div class="border-t border-gray-200 pt-6">
<h4 class="text-md font-medium text-gray-900 mb-3">Preview</h4>
<div class="border border-gray-200 rounded p-4 bg-gray-50">
<div class="font-medium text-gray-900 mb-2">Subject: {emailData.subject || 'No subject'}</div>
<div class="text-gray-700 whitespace-pre-line">{emailData.body || 'No content'}</div>
</div>
</div>
</div>
{/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 -->
<div class="flex items-center justify-between">
<button
onclick={prevStep}
disabled={currentStep === 0}
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<div class="flex gap-2">
{#if currentStep < totalSteps - 1}
<button
onclick={nextStep}
disabled={currentStep === 0 ? !authData.isConnected : !canProceed}
class="px-6 py-2 bg-blue-600 text-white font-semibold rounded hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
{:else}
<button
onclick={createEvent}
disabled={!canProceed || loading}
class="px-6 py-2 bg-green-600 text-white font-semibold rounded hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Creating...' : 'Create Event'}
</button>
{/if}
</div>
</div>
</div>