From 822f1a73425bfa31cada633a9006f36b6485000c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Wed, 2 Jul 2025 21:50:45 +0200 Subject: [PATCH] First stage of the new flow --- .env.example | 7 +- .github/copilot-instructions.md | 83 +- src/lib/gmail.ts | 80 ++ src/lib/google-server.ts | 38 + src/lib/google.ts | 144 ++-- src/lib/sheets.ts | 58 ++ src/routes/api/auth/refresh/+server.ts | 30 + .../api/sheets/[sheetId]/data/+server.ts | 22 + src/routes/api/sheets/recent/+server.ts | 20 + src/routes/auth/google/+server.ts | 8 + src/routes/auth/google/callback/+server.ts | 110 +++ src/routes/private/api/gmail/+server.ts | 2 +- src/routes/private/events/+page.svelte | 53 +- .../private/events/event/new/+page.server.ts | 7 + .../private/events/event/new/+page.svelte | 795 ++++++++++++++++++ 15 files changed, 1317 insertions(+), 140 deletions(-) create mode 100644 src/lib/gmail.ts create mode 100644 src/lib/google-server.ts create mode 100644 src/lib/sheets.ts create mode 100644 src/routes/api/auth/refresh/+server.ts create mode 100644 src/routes/api/sheets/[sheetId]/data/+server.ts create mode 100644 src/routes/api/sheets/recent/+server.ts create mode 100644 src/routes/auth/google/+server.ts create mode 100644 src/routes/auth/google/callback/+server.ts create mode 100644 src/routes/private/events/event/new/+page.server.ts create mode 100644 src/routes/private/events/event/new/+page.svelte diff --git a/.env.example b/.env.example index e3df937..32ce1d1 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,7 @@ PUBLIC_SUPABASE_URL=https://abc.supabase.co -PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s \ No newline at end of file +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 \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c53c216..a67c7e3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 variable obtained from let { data } = $props(); -Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead \ No newline at end of file +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 + + diff --git a/src/lib/gmail.ts b/src/lib/gmail.ts new file mode 100644 index 0000000..c46488d --- /dev/null +++ b/src/lib/gmail.ts @@ -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 ` + + + + + +
+

${text}

+ QR Code +
+
+
+
+
+
+
+
+

This email has been generated with the help of ScanWave

+
+
+ +`; +} + +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: ', + '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 } + }); +} diff --git a/src/lib/google-server.ts b/src/lib/google-server.ts new file mode 100644 index 0000000..7d98157 --- /dev/null +++ b/src/lib/google-server.ts @@ -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; +} diff --git a/src/lib/google.ts b/src/lib/google.ts index 792deb2..718199b 100644 --- a/src/lib/google.ts +++ b/src/lib/google.ts @@ -1,108 +1,60 @@ -import { google } from 'googleapis'; -import { env } from '$env/dynamic/private'; -import quotedPrintable from 'quoted-printable'; // tiny, zero-dep package +import { browser } from '$app/environment'; +// Client-side only functions export const scopes = [ '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() { - return new google.auth.OAuth2( - env.GOOGLE_CLIENT_ID, - env.GOOGLE_CLIENT_SECRET, - env.GOOGLE_REDIRECT_URI - ); +// Client-side functions for browser environment +export async function initGoogleAuth(): Promise { + if (!browser) return; + // Google Auth initialization is handled by the OAuth flow + // No initialization needed for our server-side approach } -export function createAuthUrl() { - return getOAuthClient().generateAuthUrl({ - access_type: 'offline', - prompt: 'consent', - scope: scopes - }); +export function getAuthUrl(): string { + if (!browser) return ''; + // This should be obtained from the server + return '/auth/google'; } -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 async function isTokenValid(accessToken: string): Promise { + if (!browser) return false; + + 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( - refreshToken: string, - { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string } -) { - const oauth = getOAuthClient(); - oauth.setCredentials({ refresh_token: refreshToken }); - - const gmail = google.gmail({ version: 'v1', auth: oauth }); - - const message_html = - ` - - - - - -
-

${text}

- QR Code -
-
-
-
-
-
-
-
-

This email has been generated with the help of ScanWave

-
-
- -`; - - 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: ', - '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 } - }); +export async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refreshToken }) + }); + + if (response.ok) { + const data = await response.json(); + return data.accessToken; + } + return null; + } catch (error) { + console.error('Error refreshing token:', error); + return null; + } } diff --git a/src/lib/sheets.ts b/src/lib/sheets.ts new file mode 100644 index 0000000..b8dcc4c --- /dev/null +++ b/src/lib/sheets.ts @@ -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 { + 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 { + 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; +} diff --git a/src/routes/api/auth/refresh/+server.ts b/src/routes/api/auth/refresh/+server.ts new file mode 100644 index 0000000..e6561ee --- /dev/null +++ b/src/routes/api/auth/refresh/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/sheets/[sheetId]/data/+server.ts b/src/routes/api/sheets/[sheetId]/data/+server.ts new file mode 100644 index 0000000..263e099 --- /dev/null +++ b/src/routes/api/sheets/[sheetId]/data/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/sheets/recent/+server.ts b/src/routes/api/sheets/recent/+server.ts new file mode 100644 index 0000000..d9b94a2 --- /dev/null +++ b/src/routes/api/sheets/recent/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/auth/google/+server.ts b/src/routes/auth/google/+server.ts new file mode 100644 index 0000000..d407c22 --- /dev/null +++ b/src/routes/auth/google/+server.ts @@ -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); +}; diff --git a/src/routes/auth/google/callback/+server.ts b/src/routes/auth/google/callback/+server.ts new file mode 100644 index 0000000..840490c --- /dev/null +++ b/src/routes/auth/google/callback/+server.ts @@ -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 = ` + + + + Google Authentication Success + + + +
+
✓ Authentication successful!
+
Closing window...
+
+ + +`; + + 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'); + } +}; diff --git a/src/routes/private/api/gmail/+server.ts b/src/routes/private/api/gmail/+server.ts index ca0e7d4..62b73e8 100644 --- a/src/routes/private/api/gmail/+server.ts +++ b/src/routes/private/api/gmail/+server.ts @@ -3,9 +3,9 @@ import { json, redirect } from '@sveltejs/kit'; import { createAuthUrl, exchangeCodeForTokens, - sendGmail, getOAuthClient } from '$lib/google'; +import { sendGmail } from '$lib/gmail'; /* ───────────── GET ───────────── */ export const GET: RequestHandler = async ({ url }) => { diff --git a/src/routes/private/events/+page.svelte b/src/routes/private/events/+page.svelte index 47dc1f6..01b0edc 100644 --- a/src/routes/private/events/+page.svelte +++ b/src/routes/private/events/+page.svelte @@ -1,53 +1,24 @@

All Events

- {#if loading} - {#each Array(4) as _} - New Event diff --git a/src/routes/private/events/event/new/+page.server.ts b/src/routes/private/events/event/new/+page.server.ts new file mode 100644 index 0000000..036c394 --- /dev/null +++ b/src/routes/private/events/event/new/+page.server.ts @@ -0,0 +1,7 @@ +export const load = async ({ locals }: { locals: any }) => { + const { session } = await locals.safeGetSession(); + + return { + session + }; +}; diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte new file mode 100644 index 0000000..bad1c12 --- /dev/null +++ b/src/routes/private/events/event/new/+page.svelte @@ -0,0 +1,795 @@ + + +
+ +
+

Create New Event

+
+ {#each Array(totalSteps) as _, index} +
+
+ {index + 1} +
+ {#if index < totalSteps - 1} +
+ {/if} +
+ {/each} +
+

Step {currentStep + 1} of {totalSteps}: {stepTitle}

+
+ + +
+ {#if currentStep === 0} + +
+
+

Connect Your Google Account

+

+ To create events and import participants from Google Sheets, you need to connect your Google account. +

+ + {#if authData.checking} +
+
+ Checking connection... +
+ {:else if authData.isConnected} +
+
+
+ + + +
+
+

+ Google account connected successfully! +

+

+ You can now access Google Sheets and Gmail features. +

+
+
+
+ {:else} +
+
+
+ + + +
+
+

+ Google account not connected +

+

+ Please connect your Google account to continue with event creation. +

+
+
+
+ +
+ + + {#if authData.connecting && authData.showCancelOption} + +

+ Taking too long? You can cancel and try again. +

+ {/if} +
+ {/if} + + {#if authData.error} +
+
+
+ + + +
+
+

+ Connection Error +

+

+ {authData.error} +

+
+
+
+ {/if} + + {#if errors.auth} +

{errors.auth}

+ {/if} +
+
+ + {:else if currentStep === 1} + +
+
+ + + {#if errors.name} +

{errors.name}

+ {/if} +
+ +
+ + + {#if errors.date} +

{errors.date}

+ {/if} +
+
+ + {:else if currentStep === 2} + +
+
+

Select Google Sheet

+ + {#if sheetsData.loading && sheetsData.availableSheets.length === 0} +
+ {#each Array(5) as _} +
+
+
+
+ {/each} +
+ {:else if sheetsData.availableSheets.length === 0} +
+

No Google Sheets found.

+ +
+ {:else} +
+ {#if !sheetsData.expandedSheetList && sheetsData.selectedSheet} + +
+
+
{sheetsData.selectedSheet.name}
+
+ Modified: {new Date(sheetsData.selectedSheet.modifiedTime).toLocaleDateString()} +
+
+ +
+ {:else} + +
+

Available Sheets

+ {#if sheetsData.selectedSheet} + + {/if} +
+
+ {#each sheetsData.availableSheets as sheet} + + {/each} +
+ {/if} +
+ {/if} + + {#if errors.sheet} +

{errors.sheet}

+ {/if} +
+ + {#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0} +
+

Column Mapping

+ + +
+

Column Mapping Instructions:

+

+ Select what each column represents by using the dropdown in each column header. + Make sure to assign Name, Surname, Email, and Confirmation columns. +

+
+ +
+ + + + {#each sheetsData.sheetData[0] || [] as header, index} + + {/each} + + + + {#each sheetsData.sheetData.slice(0, 10) as row, rowIndex} + + {#each row as cell, cellIndex} + + {/each} + + {/each} + +
+
+
+ Column {index + 1} + ({header || 'Empty'}) +
+ + {#if sheetsData.columnMapping.name === index + 1} + Name Column + {:else if sheetsData.columnMapping.surname === index + 1} + Surname Column + {:else if sheetsData.columnMapping.email === index + 1} + Email Column + {:else if sheetsData.columnMapping.confirmation === index + 1} + Confirmation Column + {/if} +
+
+ + {cell || ''} + +
+
+

Showing first 10 rows

+
+ {/if} + + {#if sheetsData.loading && sheetsData.selectedSheet} +
+
Loading sheet data...
+
+ {/if} + + {#if errors.sheetData} +

{errors.sheetData}

+ {/if} +
+ + {:else if currentStep === 3} + +
+
+ + + {#if errors.subject} +

{errors.subject}

+ {/if} +
+ +
+ + + {#if errors.body} +

{errors.body}

+ {/if} +
+ + +
+

Preview

+
+
Subject: {emailData.subject || 'No subject'}
+
{emailData.body || 'No content'}
+
+
+
+ {/if} + + {#if errors.submit} +
+

{errors.submit}

+
+ {/if} +
+ + +
+ + +
+ {#if currentStep < totalSteps - 1} + + {:else} + + {/if} +
+
+