UX improvements for the final step.

This commit is contained in:
Roman Krček
2025-06-28 00:32:51 +02:00
parent 10badafb63
commit a7262f9815
4 changed files with 181 additions and 120 deletions

View File

@@ -84,3 +84,5 @@ 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

View File

@@ -2,7 +2,10 @@ 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 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',
'https://www.googleapis.com/auth/userinfo.email'
];
export function getOAuthClient() { export function getOAuthClient() {
return new google.auth.OAuth2( return new google.auth.OAuth2(
@@ -61,11 +64,13 @@ export async function sendGmail(
</body> </body>
</html>`; </html>`;
const boundary = 'BOUNDARY'; const boundary = 'BOUNDARY';
const nl = '\r\n'; // RFC-5322 line ending const nl = '\r\n'; // RFC-5322 line ending
const htmlQP = quotedPrintable.encode(message_html); // 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 qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl);
const rawParts = [ const rawParts = [

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
let { data } = $props(); let { data } = $props();
let session_storage_id = page.url.searchParams.get('data'); let session_storage_id = page.url.searchParams.get('data');
let all_data = {}; let all_data = {};
@@ -14,9 +14,13 @@
Failure: 'failure' Failure: 'failure'
} as const; } as const;
type StepStatus = (typeof StepStatus)[keyof typeof StepStatus]; type StepStatus = (typeof StepStatus)[keyof typeof StepStatus];
let supabase_status: StepStatus = $state(StepStatus.Waiting); let event_status: StepStatus = $state(StepStatus.Waiting);
let participants_status: StepStatus = $state(StepStatus.Waiting);
let email_status: StepStatus = $state(StepStatus.Waiting); let email_status: StepStatus = $state(StepStatus.Waiting);
let createdParticipants = $state([]);
let mailStatuses = $state([]); // { email, name, status: 'pending' | 'success' | 'failure' }
onMount(async () => { onMount(async () => {
if (!session_storage_id) { if (!session_storage_id) {
console.error('No session storage ID provided in the URL'); console.error('No session storage ID provided in the URL');
@@ -24,54 +28,34 @@
} }
all_data = JSON.parse(sessionStorage.getItem(session_storage_id) || '{}'); all_data = JSON.parse(sessionStorage.getItem(session_storage_id) || '{}');
supabase_status = StepStatus.Loading;
try { try {
const { result } = await insert_data_supabase(all_data.participants, all_data.event); event_status = StepStatus.Loading;
supabase_status = StepStatus.Success; const createdEvent = await createEventInSupabase(all_data.event);
// Now send emails event_status = StepStatus.Success;
email_status = StepStatus.Loading;
let allSuccess = true; participants_status = StepStatus.Loading;
for (const obj of result) { createdParticipants = await createParticipantsInSupabase(all_data.participants, createdEvent);
let qr_code = await dataToBase64(obj.id); participants_status = StepStatus.Success;
const payload = {
action: 'send',
to: obj.email,
subject: all_data.email.subject,
text: all_data.email.body,
qr_code: qr_code,
refreshToken: localStorage.getItem('gmail_refresh_token')
};
const res = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
allSuccess = false;
console.error('Failed to send email to', obj.email, await res.text());
}
}
email_status = allSuccess ? StepStatus.Success : StepStatus.Failure;
} catch (e) { } catch (e) {
supabase_status = StepStatus.Failure; if (event_status === StepStatus.Loading) event_status = StepStatus.Failure;
email_status = StepStatus.Failure; else participants_status = StepStatus.Failure;
console.error(e); console.error(e);
} }
}); });
async function dataToBase64(data: string): Promise<string> { async function createEventInSupabase(event) {
try { console.log('Creating event in Supabase:', event);
const url = await QRCode.toDataURL(data); const { data: createdEvent, error: eventError } = await data.supabase.rpc('create_event', {
const parts = url.split(','); p_name: event.name,
const base64 = parts[1]; p_date: event.date
return base64; });
} catch (err) { console.log('Created event:', createdEvent);
console.error(err); if (eventError) throw eventError;
return ''; return createdEvent;
} }
}
async function insert_data_supabase(participants, event) { async function createParticipantsInSupabase(participants, event) {
const names = participants.map((p) => p.name); const names = participants.map((p) => p.name);
const surnames = participants.map((p) => p.surname); const surnames = participants.map((p) => p.surname);
const emails = participants.map((p) => p.email); const emails = participants.map((p) => p.email);
@@ -91,21 +75,94 @@
p_surnames: surnames, p_surnames: surnames,
p_emails: emails p_emails: emails
}); });
if (qrCodeError) throw qrCodeError;
return result;
}
return { result }; async function sendEmails(participants, email) {
mailStatuses = participants.map((p) => ({ email: p.email, name: `${p.name} ${p.surname}`, status: 'pending' }));
let allSuccess = true;
for (let i = 0; i < participants.length; i++) {
const obj = participants[i];
let qr_code = await dataToBase64(obj.id);
const payload = {
action: 'send',
to: obj.email,
subject: email.subject,
text: email.body,
qr_code: qr_code,
refreshToken: localStorage.getItem('gmail_refresh_token')
};
try {
const res = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
mailStatuses[i].status = 'success';
} else {
mailStatuses[i].status = 'failure';
allSuccess = false;
console.error('Failed to send email to', obj.email, await res.text());
}
} catch (e) {
mailStatuses[i].status = 'failure';
allSuccess = false;
console.error('Failed to send email to', obj.email, e);
}
}
return allSuccess;
}
async function dataToBase64(data: string): Promise<string> {
try {
const url = await QRCode.toDataURL(data);
const parts = url.split(',');
const base64 = parts[1];
return base64;
} catch (err) {
console.error(err);
return '';
}
}
async function launchMailCampaign() {
email_status = StepStatus.Loading;
try {
const allSuccess = await sendEmails(createdParticipants, all_data.email);
email_status = allSuccess ? StepStatus.Success : StepStatus.Failure;
} catch (e) {
email_status = StepStatus.Failure;
console.error(e);
}
} }
</script> </script>
<!-- Creating Event Entry -->
<div class="mb-4 rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Creating event</h2>
{#if event_status === StepStatus.Waiting}
<span class="text-black-600">Waiting...</span>
{:else if event_status === StepStatus.Loading}
<span class="text-black-600">Creating event...</span>
{:else if event_status === StepStatus.Success}
<span class="text-green-600">Event created successfully.</span>
{:else if event_status === StepStatus.Failure}
<span class="text-red-600">Failed to create event.</span>
{/if}
</div>
<!-- Creating Database Entries --> <!-- Creating Database Entries -->
<div class="mb-4 rounded border border-gray-300 bg-white p-4"> <div class="mb-4 rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Creating database entries</h2> <h2 class="mb-2 text-xl font-bold">Creating database entries</h2>
{#if supabase_status === StepStatus.Waiting} {#if participants_status === StepStatus.Waiting}
<span class="text-black-600">Waiting...</span> <span class="text-black-600">Waiting...</span>
{:else if supabase_status === StepStatus.Loading} {:else if participants_status === StepStatus.Loading}
<span class="text-black-600">Creating entries...</span> <span class="text-black-600">Creating entries...</span>
{:else if supabase_status === StepStatus.Success} {:else if participants_status === StepStatus.Success}
<span class="text-green-600">Database entries created successfully.</span> <span class="text-green-600">Database entries created successfully.</span>
{:else if supabase_status === StepStatus.Failure} {:else if participants_status === StepStatus.Failure}
<span class="text-red-600">Failed to create database entries.</span> <span class="text-red-600">Failed to create database entries.</span>
{/if} {/if}
</div> </div>
@@ -114,12 +171,68 @@
<div class="rounded border border-gray-300 bg-white p-4"> <div class="rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Sending emails</h2> <h2 class="mb-2 text-xl font-bold">Sending emails</h2>
{#if email_status === StepStatus.Waiting} {#if email_status === StepStatus.Waiting}
<span class="text-black-600">Waiting...</span> <div class="flex items-center justify-between">
{:else if email_status === StepStatus.Loading} <span class="text-black-600">Waiting...</span>
<span class="text-black-600">Sending emails...</span> <button
{:else if email_status === StepStatus.Success} disabled={event_status !== StepStatus.Success || participants_status !== StepStatus.Success}
<span class="text-green-600">Emails sent successfully.</span> onclick={launchMailCampaign}
{:else if email_status === StepStatus.Failure} class="ml-4 px-6 py-2 rounded font-semibold transition disabled:opacity-50 disabled:cursor-not-allowed
<span class="text-red-600">Failed to send emails.</span> {event_status === StepStatus.Success && participants_status === StepStatus.Success ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-gray-400 text-white'}"
>
Launch Mail Campaign
</button>
</div>
{:else}
<ul class="mt-4 space-y-2">
{#each createdParticipants as p, i}
<li class="flex items-center border-b pb-1 gap-2">
{#if mailStatuses[i]?.status === 'success'}
<svg
title="Sent"
class="mr-2 inline h-4 w-4 text-green-600"
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>
{:else if mailStatuses[i]?.status === 'failure'}
<svg
title="Failed"
class="mr-2 inline h-4 w-4 text-red-600"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" />
<line x1="8" y1="8" x2="16" y2="16" stroke="currentColor" stroke-width="2" />
<line x1="16" y1="8" x2="8" y2="16" stroke="currentColor" stroke-width="2" />
</svg>
{:else}
<svg
title="Pending"
class="mr-2 inline h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
{/if}
<span class="font-semibold">{p.name} {p.surname}</span>
<span class="font-mono text-xs text-gray-600 ml-auto">{p.email}</span>
</li>
{/each}
</ul>
{#if email_status === StepStatus.Loading}
<span class="block mt-2 text-black-600">Sending emails...</span>
{:else if email_status === StepStatus.Success}
<span class="block mt-2 text-green-600">Emails sent successfully.</span>
{:else if email_status === StepStatus.Failure}
<span class="block mt-2 text-red-600">Failed to send emails.</span>
{/if}
{/if} {/if}
</div> </div>

View File

@@ -1,59 +0,0 @@
<script lang="ts">
import QRCode from 'qrcode';
const StepState = {
Waiting: 'waiting',
Processing: 'processing',
FinishedSuccess: 'finished_success',
FinishedFail: 'finished_fail'
};
let qr_codes_state = $state(StepState.Processing);
let emails_state = $state(StepState.FinishedSuccess);
// Inserts all participants into the database and returns their assigned IDs.
async function insert_data_supabase(data, participants, new_event) {
const names = participants.map((p) => p.name);
const surnames = participants.map((p) => p.surname);
const emails = participants.map((p) => p.email);
const {
data: { user },
error: authError
} = await data.supabase.auth.getUser();
const { data: user_profile, error: profileError } = await data.supabase
.from('profiles')
.select('*, section:sections (id, name)')
.eq('id', user?.id)
.single();
const { data: result, error: qrCodeError } = await data.supabase.rpc('create_qrcodes_bulk', {
p_section_id: user_profile?.section.id,
p_event_id: new_event.id,
p_names: names,
p_surnames: surnames,
p_emails: emails
});
return { result };
}
// Creates a base64 interpretation of the ticket ID
function createB64QRCode(data) {
QRCode.toDataURL('I am a pony!')
.then((url) => {
const parts = url.split(',');
return { base64data: parts[1] };
})
.catch((err) => {
console.error(err);
});
}
function sendEmail(email, subject, body, qr_code_base64) {
// Here you would implement the logic to send the email.
// This is a placeholder function.
console.log(`Sending email to ${email} with subject "${subject}" and body "${body}"`);
console.log(`QR Code Base64: ${qr_code_base64}`);
}
</script>
Pl