Merge pull request 'UX improvements' (#8) from supabase into main
All checks were successful
Build Docker image / build (push) Successful in 1m18s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 23s

Reviewed-on: erman/esn-code-scanner-app#8
This commit is contained in:
2025-06-24 19:15:46 +02:00
14 changed files with 364 additions and 113 deletions

21
package-lock.json generated
View File

@@ -12,7 +12,8 @@
"@supabase/supabase-js": "^2.50.0", "@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"googleapis": "^150.0.1", "googleapis": "^150.0.1",
"papaparse": "^5.5.3" "papaparse": "^5.5.3",
"quoted-printable": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
@@ -2691,6 +2692,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/quoted-printable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
"integrity": "sha512-cihC68OcGiQOjGiXuo5Jk6XHANTHl1K4JLk/xlEJRTIXfy19Sg6XzB95XonYgr+1rB88bCpr7WZE7D7AlZow4g==",
"license": "MIT",
"dependencies": {
"utf8": "^2.1.0"
},
"bin": {
"quoted-printable": "bin/quoted-printable"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3050,6 +3063,12 @@
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD" "license": "BSD"
}, },
"node_modules/utf8": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz",
"integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==",
"license": "MIT"
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -33,6 +33,7 @@
"@supabase/supabase-js": "^2.50.0", "@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"googleapis": "^150.0.1", "googleapis": "^150.0.1",
"papaparse": "^5.5.3" "papaparse": "^5.5.3",
"quoted-printable": "^1.0.1"
} }
} }

View File

@@ -1,5 +1,6 @@
import { google } from 'googleapis'; import { google } from 'googleapis';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import quotedPrintable from 'quoted-printable'; // tiny, zero-dep package
export const scopes = ['https://www.googleapis.com/auth/gmail.send']; export const scopes = ['https://www.googleapis.com/auth/gmail.send'];
@@ -27,21 +28,73 @@ export async function exchangeCodeForTokens(code: string) {
export async function sendGmail( export async function sendGmail(
refreshToken: string, refreshToken: string,
{ to, subject, text }: { to: string; subject: string; text: string } { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string }
) { ) {
const oauth = getOAuthClient(); const oauth = getOAuthClient();
oauth.setCredentials({ refresh_token: refreshToken }); oauth.setCredentials({ refresh_token: refreshToken });
const gmail = google.gmail({ version: 'v1', auth: oauth }); const gmail = google.gmail({ version: 'v1', auth: oauth });
const raw = Buffer
.from( const message_html =
[`To: ${to}`, `<!DOCTYPE html>
'Content-Type: text/plain; charset="UTF-8"', <html lang="en">
'Content-Transfer-Encoding: 7bit', <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 *insert software name*</p>
</div>
</div>
</body>
</html>`;
const boundary = 'BOUNDARY';
const nl = '\r\n'; // RFC-5322 line ending
const htmlQP = quotedPrintable.encode(message_html);
const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl);
const rawParts = [
'MIME-Version: 1.0',
`To: ${to}`,
`Subject: ${subject}`, `Subject: ${subject}`,
`Content-Type: multipart/related; boundary="${boundary}"`,
'', '',
text].join('\n')) `--${boundary}`,
.toString('base64url'); '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({ await gmail.users.messages.send({
userId: 'me', userId: 'me',

View File

@@ -11,6 +11,6 @@
</ul> </ul>
</nav> </nav>
<div class="container p-2 bg-white "> <div class="container max-w-2xl mx-auto p-2 bg-white">
<slot /> <slot />
</div> </div>

View File

@@ -36,13 +36,13 @@ export const GET: RequestHandler = async ({ url }) => {
/* ───────────── POST ───────────── */ /* ───────────── POST ───────────── */
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
const { action, refreshToken, to, subject, text } = await request.json(); const { action, refreshToken, to, subject, text, qr_code } = await request.json();
/* send e-mail */ /* send e-mail */
if (action === 'send') { if (action === 'send') {
if (!refreshToken) return new Response('Missing token', { status: 401 }); if (!refreshToken) return new Response('Missing token', { status: 401 });
try { try {
await sendGmail(refreshToken, { to, subject, text }); await sendGmail(refreshToken, { to, subject, text, qr_code });
return json({ ok: true }); return json({ ok: true });
} catch (err) { } catch (err) {
return new Response((err as Error).message, { status: 500 }); return new Response((err as Error).message, { status: 500 });

View File

@@ -4,8 +4,6 @@ import Papa from 'papaparse';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
export async function load({ locals }) { export async function load({ locals }) {
console.log("▶️ fetching events…");
const { data: events, error } = await locals.supabase const { data: events, error } = await locals.supabase
.from('events') .from('events')
.select('*') .select('*')
@@ -23,8 +21,6 @@ export async function load({ locals }) {
export const actions = { export const actions = {
create: async (event) => { create: async (event) => {
const formData = await event.request.formData(); const formData = await event.request.formData();
console.log(Array.from(formData.entries()));
console.log('create_event date', formData.get("date"), formData.get("name"), formData.get("description"));
let { data: new_event, error } = await event.locals.supabase.rpc("create_event", let { data: new_event, error } = await event.locals.supabase.rpc("create_event",
{ {
"p_name": formData.get('name'), "p_name": formData.get('name'),
@@ -51,8 +47,6 @@ export const actions = {
parsedRows.shift(); parsedRows.shift();
} }
console.log('Parsed rows:', parsedRows);
// Map each row to an object with keys: name, surname, email // Map each row to an object with keys: name, surname, email
const participants = parsedRows.map((row: string[]) => ({ const participants = parsedRows.map((row: string[]) => ({
name: row[0], name: row[0],
@@ -60,8 +54,6 @@ export const actions = {
email: row[2] email: row[2]
})); }));
console.log('Mapped participants:', participants);
return { return {
participants, participants,
} }

View File

@@ -11,8 +11,8 @@
let participants = $state([]); let participants = $state([]);
let subject = $state(''); let subject = $state('');
let body = $state(''); let body = $state('');
let authorized = $state(false);
// Update events and participants from the form data
$effect(() => { $effect(() => {
if (form && form.new_event) { if (form && form.new_event) {
new_event = form.new_event; new_event = form.new_event;
@@ -31,36 +31,26 @@
StepOverview StepOverview
]; ];
// State variable for current step let step: number = $state(0);
let step = $state(0);
let stepConditions = $derived([
authorized,
!!new_event?.name,
!!participants?.length,
!!subject && !!body
]);
// Helper to go to next/previous step (optional)
function nextStep() { function nextStep() {
if (step < steps.length - 1) step += 1; if (step < steps.length - 1) step += 1;
console.log(step);
} }
function prevStep() { function prevStep() {
if (step > 0) step -= 1; if (step > 0) step -= 1;
console.log(step);
} }
</script> </script>
<!-- Render the current step component --> <div class="flex items-center justify-between mb-4 mt-2">
{#if step == 0}
<StepConnectGoogle />
{:else if step == 1}
<StepCreateEvent events={data.events} {new_event} />
{:else if step == 2}
<StepUploadFiles {participants} />
{:else if step == 3}
<StepCraftEmail bind:subject bind:body />
{:else if step == 4}
<StepOverview {new_event} {participants} {subject} {body} />
{/if}
<div class="flex items-center justify-between mt-4">
<button <button
on:click={prevStep} onclick={prevStep}
disabled={step === 0} disabled={step === 0}
class="min-w-[100px] py-2 px-4 bg-white border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" class="min-w-[100px] py-2 px-4 bg-white border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition"
> >
@@ -70,10 +60,28 @@
Step {step + 1} of {steps.length} Step {step + 1} of {steps.length}
</span> </span>
<button <button
on:click={nextStep} onclick={nextStep}
disabled={step === steps.length - 1} disabled={step === steps.length - 1 || !stepConditions[step]}
class="min-w-[100px] py-2 px-4 bg-white border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" class="min-w-[100px] py-2 px-4 bg-white border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition"
> >
Next Next
</button> </button>
</div> </div>
{#if step == 0}
<StepConnectGoogle bind:authorized />
{:else if step == 1}
<StepCreateEvent events={data.events} {new_event} />
{:else if step == 2}
<StepUploadFiles {participants} />
{:else if step == 3}
<StepCraftEmail bind:subject bind:body />
{:else if step == 4}
<StepOverview
{new_event}
{participants}
{subject}
{body}
{stepConditions}
/>
{/if}

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
let to = '';
let subject = '';
let body = '';
let qrcode_b64 = '';
let loading = false;
let error = '';
let success = '';
let authorized = false;
let refreshToken = '';
async function validateToken(token: string): Promise<boolean> {
if (!token) return false;
const res = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'validate', refreshToken: token })
});
if (!res.ok) return false;
const data = await res.json();
return !!data.valid;
}
onMount(async () => {
refreshToken = localStorage.getItem('gmail_refresh_token') ?? '';
authorized = await validateToken(refreshToken);
});
const connect = () => goto('/private/api/gmail?action=auth');
async function sendTestEmail() {
error = '';
success = '';
loading = true;
try {
const r = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'send',
to,
subject,
text: body,
qr_code: qrcode_b64,
refreshToken
})
});
if (r.ok) {
success = 'Email sent!';
to = subject = body = qrcode_b64 = '';
} else {
error = await r.text();
}
} catch (e) {
error = e.message || 'Unknown error';
}
loading = false;
}
</script>
<div class="max-w-lg mx-auto bg-white border border-gray-300 rounded p-8 mt-8 shadow">
<h2 class="text-2xl font-semibold mb-6 text-center">Test Email Sender</h2>
{#if !authorized}
<div class="mb-4 flex items-center justify-between">
<p class="text-gray-700">Google not connected.</p>
<button class="btn bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded ml-auto" on:click={connect}>
Connect Google
</button>
</div>
{:else}
<form on:submit|preventDefault={sendTestEmail} class="space-y-4">
<label class="block">
<span class="text-gray-700">To</span>
<input type="email" class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200" bind:value={to} required />
</label>
<label class="block">
<span class="text-gray-700">Subject</span>
<input type="text" class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200" bind:value={subject} required />
</label>
<label class="block">
<span class="text-gray-700">Body</span>
<textarea class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200 resize-none" rows="6" bind:value={body} required></textarea>
</label>
<label class="block">
<span class="text-gray-700">QR Code (base64, data:image/png;base64,...)</span>
<input type="text" class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200 font-mono text-xs" bind:value={qrcode_b64} placeholder="Paste base64 image string here" required />
</label>
<button type="submit" class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition" disabled={loading}>
{#if loading}
<svg class="animate-spin h-5 w-5 mr-2 inline-block text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
Sending...
{:else}
Send Test Email
{/if}
</button>
</form>
{/if}
{#if error}
<div class="rounded border-l-4 border-red-500 bg-red-100 p-4 text-red-700 mt-4">{error}</div>
{/if}
{#if success}
<div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700 mt-4">{success}</div>
{/if}
</div>

View File

@@ -2,8 +2,10 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
export let authorized = false;
let refreshToken = ''; let refreshToken = '';
let authorized = false; let loading = true;
let to = ''; let to = '';
let subject = ''; let subject = '';
@@ -22,30 +24,15 @@
} }
onMount(async () => { onMount(async () => {
console.log("on mount");
refreshToken = localStorage.getItem('gmail_refresh_token') ?? ''; refreshToken = localStorage.getItem('gmail_refresh_token') ?? '';
loading = true;
authorized = await validateToken(refreshToken); authorized = await validateToken(refreshToken);
loading = false;
}); });
/* ⇢ redirects straight to Google via server 302 */ /* ⇢ redirects straight to Google via server 302 */
const connect = () => goto('/private/api/gmail?action=auth'); const connect = () => goto('/private/api/gmail?action=auth');
async function sendEmail() {
const r = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'send',
to,
subject,
text: body,
refreshToken
})
});
r.ok ? alert('Sent!') : alert(await r.text());
to = subject = body = '';
}
async function disconnect() { async function disconnect() {
if (!confirm('Disconnect Google account?')) return; if (!confirm('Disconnect Google account?')) return;
await fetch('/private/api/gmail', { await fetch('/private/api/gmail', {
@@ -59,11 +46,30 @@
} }
</script> </script>
<div class="mb-4 rounded border border-gray-300 bg-white p-4">
{#if loading}
<div class="flex items-center space-x-2">
<svg class="animate-spin h-5 w-5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
<span>Checking Google connection...</span>
</div>
{:else}
{#if !authorized} {#if !authorized}
<section class="space-y-4"> <section class="flex items-center justify-between w-full">
<p>You havent connected your Google account yet.</p> <p class="mr-4">You havent connected your Google account yet.</p>
<button class="btn" on:click={connect}>Connect Google</button> <button class="btn bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded ml-auto" on:click={connect}>
Connect Google
</button>
</section> </section>
{:else} {:else}
Your connection is good, proceed to next step <div class="flex items-center space-x-2 text-green-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>Your connection to Google is good, proceed to next step</span>
</div>
{/if} {/if}
{/if}
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
export let subject = ''; export let subject: string;
export let body = ''; export let body: string;
</script> </script>
<form class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none"> <form class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none">

View File

@@ -2,11 +2,20 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
let { events, new_event } = $props(); let { events, new_event } = $props();
let loading = $state(false);
function handleEnhance() {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}
</script> </script>
<form method="POST" action="?/create" use:enhance class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none"> <form method="POST" action="?/create" use:enhance={handleEnhance} class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none">
<h2 class="text-2xl font-semibold text-center mb-4">Create Event</h2> <h2 class="text-2xl font-semibold text-center mb-4">Create Event</h2>
<label class="flex flex-col text-gray-700"> <label class="flex flex-col text-gray-700">
Name Name
@@ -43,7 +52,15 @@
</button> </button>
</form> </form>
{#if Object.keys(new_event).length > 0} {#if Object.keys(new_event).length === 0}
<div class="mt-4 rounded border-l-4 border-gray-500 bg-gray-100 p-4 text-gray-700">
{#if loading}
<strong>Loading...</strong>
{:else}
<strong>No event created yet...</strong>
{/if}
</div>
{:else}
<div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700 mt-4"> <div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700 mt-4">
<ol> <ol>
<li><strong>{new_event.name}</strong></li> <li><strong>{new_event.name}</strong></li>

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
let { new_event, participants, subject, body } = $props(); let { new_event, participants, subject, body, stepConditions } = $props();
</script> </script>
<!-- New Event Overview --> <!-- New Event Overview -->
<div class="bg-white border border-gray-300 rounded p-6 mb-6"> <div class="mb-4 rounded border border-gray-300 bg-white p-4">
<h2 class="text-xl font-bold mb-2">Event Overview</h2> <h2 class="mb-2 text-xl font-bold">Event Overview</h2>
<ul class="space-y-1"> <ul class="space-y-1">
<li><span class="font-semibold">Name:</span> {new_event.name}</li> <li><span class="font-semibold">Name:</span> {new_event.name}</li>
<li><span class="font-semibold">Date:</span> {new_event.date}</li> <li><span class="font-semibold">Date:</span> {new_event.date}</li>
@@ -13,23 +13,50 @@
</div> </div>
<!-- Email Overview --> <!-- Email Overview -->
<div class="bg-white border border-gray-300 rounded p-6 mb-6"> <div class="mb-4 rounded border border-gray-300 bg-white p-4">
<h2 class="text-xl font-bold mb-2">Email Preview</h2> <h2 class="mb-2 text-xl font-bold">Email Preview</h2>
<div class="mb-2"><span class="font-semibold">Subject:</span> {subject}</div> <div class="mb-2"><span class="font-semibold">Subject:</span> {subject}</div>
<div class="whitespace-pre-line border rounded p-2 bg-gray-50 text-gray-700"><span class="font-semibold">Body:</span> <div class="rounded border bg-gray-50 p-2 whitespace-pre-line text-gray-700">
<div class="mt-1">{body}</div> <span class="font-semibold"></span>
<div>{body}</div>
</div> </div>
</div> </div>
<!-- Participants Overview --> <!-- Participants Overview -->
<div class="bg-white border border-gray-300 rounded p-6"> <div class="rounded border border-gray-300 bg-white p-4">
<h2 class="text-xl font-bold mb-2">Participants ({participants.length})</h2> <h2 class="mb-2 text-xl font-bold">Participants ({participants.length})</h2>
<ul class="space-y-1"> <ul class="space-y-1">
{#each participants as p} {#each participants.slice(0, 10) as p}
<li class="flex items-center gap-2 border-b last:border-b-0 pb-1"> <li class="flex items-center gap-2 border-b pb-1 last:border-b-0">
<span class="font-semibold">{p.name} {p.surname}</span> <span class="font-semibold">{p.name} {p.surname}</span>
<span class="font-mono text-xs text-gray-600">{p.email}</span> <span class="flex-1"></span>
<span class="text-right font-mono text-xs text-gray-600">{p.email}</span>
</li> </li>
{/each} {/each}
</ul> </ul>
<p class="mt-2 text-sm text-gray-500">Note: Only the first 10 participants are shown.</p>
</div>
<button
class="mt-4 w-full rounded bg-blue-600 px-4 py-3 font-bold text-white
transition-colors duration-200 hover:bg-blue-700
disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500"
disabled={!stepConditions.every(Boolean)}
>
Generate QR codes and send
</button>
<div class="mt-2 space-y-1">
{#if !stepConditions[0]}
<p class="text-sm text-red-500">Please provide an event name before proceeding.</p>
{/if}
{#if !stepConditions[1]}
<p class="text-sm text-red-500">Please add at least one participant before proceeding.</p>
{/if}
{#if !stepConditions[2]}
<p class="text-sm text-red-500">Please provide an email subject before proceeding.</p>
{/if}
{#if !stepConditions[3]}
<p class="text-sm text-red-500">Please provide an email body before proceeding.</p>
{/if}
</div> </div>

View File

@@ -2,11 +2,26 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
let { participants = [] } = $props(); let { participants = [] } = $props();
let loading = $state(false);
function handleEnhance() {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}
</script> </script>
<form method="POST" action="?/participants" use:enhance enctype="multipart/form-data" class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none"> <form
<h2 class="text-2xl font-semibold text-center mb-4">Upload Participants</h2> method="POST"
action="?/participants"
use:enhance={handleEnhance}
enctype="multipart/form-data"
class="flex w-full flex-col space-y-4 rounded border border-gray-300 bg-white p-8 shadow-none"
>
<h2 class="mb-4 text-center text-2xl font-semibold">Upload Participants</h2>
<label class="flex flex-col text-gray-700"> <label class="flex flex-col text-gray-700">
CSV File CSV File
<input <input
@@ -14,30 +29,34 @@
name="participants" name="participants"
id="participants" id="participants"
accept=".csv" accept=".csv"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200" class="mt-1 rounded border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-200 focus:outline-none"
required required
/> />
</label> </label>
<button <button
type="submit" type="submit"
class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition" class="w-full rounded bg-blue-600 py-2 text-white transition hover:bg-blue-700"
> >
Submit Submit
</button> </button>
</form> </form>
{#if participants.length === 0} {#if participants.length === 0}
<p class="text-gray-500 mt-4">No participants added yet.</p> <div class="mt-4 rounded border-l-4 border-gray-500 bg-gray-100 p-4 text-gray-700">
{#if loading}
<strong>Loading...</strong>
{:else}
<strong>No participants yet...</strong>
{/if} {/if}
</div>
{#if participants.length > 0} {:else}
<div class="rounded border-l-4 border-green-500 bg-green-50 p-4 text-green-700 mt-4"> <div class="mt-4 rounded border-l-4 border-green-500 bg-green-50 p-4 text-green-700">
<ul class="space-y-2"> <ul class="space-y-2">
{#each participants as p, i} {#each participants as p, i}
<li class="flex items-center justify-between border-b pb-1"> <li class="flex items-center justify-between border-b pb-1">
<div> <div>
<div class="font-semibold">{p.name} {p.surname}</div> <div class="font-semibold">{p.name} {p.surname}</div>
<div class="text-xs font-mono text-gray-600">{p.email}</div> <div class="font-mono text-xs text-gray-600">{p.email}</div>
</div> </div>
</li> </li>
{/each} {/each}

View File

@@ -12,7 +12,6 @@
$effect(() => { $effect(() => {
if (scanned_id === "") return; if (scanned_id === "") return;
console.log('New QR code found:', scanned_id);
scan_state = ScanState.scanning; scan_state = ScanState.scanning;
data.supabase data.supabase