feat: join course via QR code (#350)

* chore: install dependencies

* feat: create downloadable image component

* feat: update component design and add download qr button

* feat: update file name

* feat: increase border radius

* chore: add logo svg file

* refactor: move all logic to parent component

* feat: setup writeable store variable

* chore: fix ts error

* feat: setup download function in parent comp

* chore: swap out svelte/qr comp for js comp

* feat: download speed qr code improvements

* feat: add loading state to download button

* fix: responsive adjustments

* refactoring

---------

Co-authored-by: tunny17 <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Iniubong Obonguko
2024-06-08 05:20:12 +01:00
committed by GitHub
co-authored by tunny17 Rotimi Best
parent 5e7726c6ad
commit f3e0f9b7e2
7 changed files with 198 additions and 15 deletions
+2
View File
@@ -45,6 +45,7 @@
"dayjs": "^1.11.10",
"form-data": "^4.0.0",
"hotkeys-js": "^3.11.2",
"html-to-image": "^1.11.11",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"openai-edge": "^1.2.2",
@@ -84,6 +85,7 @@
"jest": "^29.7.0",
"postcss": "^8.4.24",
"postcss-load-config": "^4.0.1",
"qrcode": "^1.5.3",
"sass": "^1.64.2",
"svelte": "^4.1.2",
"svelte-calendar": "^3.1.6",
@@ -1,20 +1,23 @@
<script lang="ts">
import copy from 'copy-to-clipboard';
import { Popover } from 'carbon-components-svelte';
import Link from 'carbon-icons-svelte/lib/Link.svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import QRCode from 'qrcode';
import { toPng } from 'html-to-image';
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { ROLE } from '$lib/utils/constants/roles';
import { addGroupMember, fetchGroup } from '$lib/utils/services/courses';
import { course, setCourse } from '$lib/components/Course/store';
import { MultiSelect, Loading } from 'carbon-components-svelte';
import { currentOrg, currentOrgDomain, currentOrgPath } from '$lib/utils/store/org';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { getOrgTeam } from '$lib/utils/services/org';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { qrInviteNodeStore } from './store';
import { getStudentInviteLink } from '$lib/utils/functions/course';
import ShareQrImage from './ShareQRImage.svelte';
import {
triggerSendEmail,
NOTIFICATION_NAME
@@ -35,6 +38,8 @@
let selectedTutors: Tutor[] = [];
let isLoadingTutors = false;
let copied = false;
let qrImage = '';
let isLoadingQRDownload = false;
function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
return value !== null && value !== undefined;
@@ -48,7 +53,7 @@
return;
}
let membersStack = [];
let membersStack: { profile_id?: string; group_id?: string; role_id: number }[] = [];
console.log(`$course`, $course);
for (const tutor of selectedTutors) {
@@ -135,14 +140,47 @@
goto($page.url.pathname);
}
function handleQRDownload() {
if (!$qrInviteNodeStore) {
console.error('Node is not defined');
return;
}
isLoadingQRDownload = true;
setTimeout(() => {
toPng($qrInviteNodeStore)
.then((dataUrl) => {
const link = document.createElement('a');
link.download = `${$course.slug}-qr-code.png`;
link.href = dataUrl;
link.click();
})
.catch((error) => {
isLoadingQRDownload = false;
console.error('Oops, something went wrong!', error);
})
.finally(() => {
isLoadingQRDownload = false;
});
}, 300);
}
async function generateQR(text) {
try {
qrImage = await QRCode.toDataURL(text);
} catch (err) {
console.error(err);
}
}
$: {
selectedTutors = formatSelected(selectedIds);
console.log('selectedTutors', selectedTutors);
const query = new URLSearchParams($page.url.search);
addPeopleParm = query.get('add');
}
$: setTutors($currentOrg.id);
$: generateQR(getStudentInviteLink($course, $currentOrg.siteName, $currentOrgDomain));
</script>
<Modal
@@ -178,7 +216,7 @@
{/if}
</div>
<div class="mb-8 w-[90%] flex justify-between items-center">
<div class="mb-8 w-full flex justify-between items-center">
<div class="w-3/5">
<p class="text-base mb-1 font-semibold">
{$t('course.navItem.people.invite_modal.invite_students')}
@@ -187,21 +225,45 @@
</div>
<div class="relative">
<PrimaryButton
disablePadding={true}
className="text-sm py-2 px-3"
variant={VARIANTS.OUTLINED}
onClick={copyLink}
<button
type="button"
on:click={copyLink}
class="underline text-primary-800 font-bold cursor-pointer capitalize"
>
<Link size={16} class="mr-1" />
{$t('course.navItem.people.invite_modal.copy_link')}
</PrimaryButton>
<Popover caret open={copied} align="bottom">
</button>
<Popover caret open={copied} align="left">
<div style="padding: 5px">{$t('course.navItem.people.invite_modal.success')}</div>
</Popover>
</div>
</div>
<div
class="flex gap-5 flex-col-reverse md:flex-row justify-between items-center md:items-stretch p-4 w-full border rounded-md"
>
<div class="flex flex-col gap-3 items-center md:items-start justify-between">
<span class="font-medium text-sm">
{$t('course.navItem.people.invite_modal.via_qr')}
</span>
<PrimaryButton
isLoading={isLoadingQRDownload}
onClick={handleQRDownload}
label={$t('course.navItem.people.invite_modal.download_qr')}
className="font-medium"
/>
</div>
<div class="w-full md:w-28 border-4 p-1 border-[#f7f7f7]">
<img src={qrImage} alt="link qrcode" class="w-full h-full" />
</div>
</div>
<div class="absolute left-[-1000px] w-[40rem]">
<ShareQrImage {qrImage} course={$course} currentOrg={$currentOrg} />
</div>
<div class="mt-5 flex items-center flex-row-reverse">
<PrimaryButton
className="px-6 py-3"
@@ -0,0 +1,35 @@
<script lang="ts">
import type { CurrentOrg } from '$lib/utils/types/org.ts';
import type { Course } from '$lib/utils/types/index.ts';
import { isFreePlan } from '$lib/utils/store/org';
import { qrInviteNodeStore } from './store';
let node: any;
$: qrInviteNodeStore.set(node);
export let qrImage: string;
export let currentOrg: CurrentOrg;
export let course: Course;
</script>
<div
bind:this={node}
id="qr-container"
class="flex flex-col items-center justify-center h-[60rem] w-[40rem] bg-blue-900 rounded-xl p-10 pb-20"
>
<div class="bg-white pb-3 p-6 rounded-3xl text-center">
<div class="font-bold text-xl p-2 bg-gray-100 my-4 rounded-xl">Scan QR</div>
<img src={qrImage} alt="qrcode" />
<div class="pb-4 pt-1">
<p class="mt-2 font-bold text-2xl text-primary-600">{course.title}</p>
<p class="mt-1 font-semibold text-lg text-black">{currentOrg.name}</p>
</div>
</div>
{#if $isFreePlan}
<div class="bg-white flex items-center gap-x-2 px-4 py-2 rounded-md relative top-16">
<img src="/logo.svg" alt="logo" class="w-6" />
<span class="font-bold text-base">ClassroomIO.com</span>
</div>
{/if}
</div>
@@ -3,3 +3,5 @@ import { writable } from 'svelte/store';
export const deleteMemberModal = writable({
open: false
});
export const qrInviteNodeStore = writable(null);
@@ -803,6 +803,9 @@
"go_to": "go to organization settings",
"invite_students": "Invite Students",
"via_link": "You can invite students via an invite link",
"via_qr": "You can invite students via QR",
"download_qr": "Download QR code",
"qr_downloading_snackbar": "QR code generated successfully, please wait while it downloads",
"copy_link": "Copy link",
"success": "Copied Successfully",
"finish": "Finish"
+5
View File
@@ -0,0 +1,5 @@
<svg width="33" height="24" viewBox="0 0 33 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.153 12.5745C29.5145 12.5745 32.2395 9.84943 32.2395 6.48791C32.2395 3.1264 29.5145 0.401367 26.153 0.401367C22.7915 0.401367 20.0664 3.1264 20.0664 6.48791C20.0664 9.84943 22.7915 12.5745 26.153 12.5745Z" fill="#0542CC"/>
<path d="M32.3048 14.9824V23.8112H18.9277V20.6008C18.9277 17.5241 21.4025 14.9824 24.5461 14.9824H32.3048Z" fill="#0542CC"/>
<path d="M16.7213 0.401367V23.8112H11.7049C8.49443 23.8112 5.55148 22.4735 3.41115 20.4001C1.27082 18.2598 0 15.3168 0 12.1063C0 5.61842 5.21705 0.401367 11.7049 0.401367H16.7213Z" fill="#0542CC"/>
</svg>

After

Width:  |  Height:  |  Size: 661 B

+75 -1
View File
@@ -237,6 +237,9 @@ importers:
hotkeys-js:
specifier: ^3.11.2
version: 3.11.2
html-to-image:
specifier: ^1.11.11
version: 1.11.11
js-yaml:
specifier: ^4.1.0
version: 4.1.0
@@ -349,6 +352,9 @@ importers:
postcss-load-config:
specifier: ^4.0.1
version: 4.0.1([email protected])([email protected])
qrcode:
specifier: ^1.5.3
version: 1.5.3
sass:
specifier: ^1.64.2
version: 1.64.2
@@ -7680,6 +7686,14 @@ packages:
execa: 0.8.0
dev: false
/[email protected]:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
dev: true
/[email protected]:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
dependencies:
@@ -8870,6 +8884,10 @@ packages:
engines: {node: '>=0.3.1'}
dev: false
/[email protected]:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dev: true
/[email protected]:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -9002,6 +9020,10 @@ packages:
/[email protected]:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
/[email protected]:
resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
dev: true
/[email protected]:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
@@ -14017,6 +14039,11 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
/[email protected]:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
dev: true
/[email protected]([email protected]):
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -14475,6 +14502,17 @@ packages:
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
dev: true
/[email protected]:
resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
engines: {node: '>=10.13.0'}
hasBin: true
dependencies:
dijkstrajs: 1.0.3
encode-utf8: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
dev: true
/[email protected]:
resolution: {integrity: sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==}
engines: {node: '>=0.6'}
@@ -14867,6 +14905,10 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
/[email protected]:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
dev: true
/[email protected]:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
dev: true
@@ -15126,7 +15168,6 @@ packages:
/[email protected]:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
dev: false
/[email protected]:
resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==}
@@ -17219,6 +17260,10 @@ packages:
is-weakset: 2.0.2
dev: true
/[email protected]:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
dev: true
/[email protected]:
resolution: {integrity: sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==}
engines: {node: '>= 0.4'}
@@ -17329,6 +17374,10 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
/[email protected]:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
dev: true
/[email protected]:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -17357,6 +17406,14 @@ packages:
resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==}
engines: {node: '>= 14'}
/[email protected]:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
dev: true
/[email protected]:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
@@ -17366,6 +17423,23 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
/[email protected]:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
dev: true
/[email protected]:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}