From 84e216cc48432cddbf7d4eada02e4e172b6a284d Mon Sep 17 00:00:00 2001 From: Dave <dave.pikoppokam@student.uclouvain.be> Date: Fri, 7 Mar 2025 12:09:29 +0000 Subject: [PATCH] Registration flow --- .DS_Store | Bin 0 -> 6148 bytes ...f93e_update_survey_response_with_other_.py | 2 +- ...8c5f_add_my_slots_column_to_users_table.py | 29 + ...a922c7fc4_add_new_columns_to_user_table.py | 33 ++ backend/app/models.py | 7 +- backend/app/schemas.py | 18 +- backend/languagelab.sqbpro | 1 + frontend/src/lang/en.json | 34 +- frontend/src/lang/fr.json | 35 +- frontend/src/lang/nl.json | 7 + frontend/src/lib/types/user.ts | 75 ++- frontend/src/routes/+page.svelte | 126 ++--- .../register/[[studyId]]/+page.server.ts | 87 +-- .../routes/register/[[studyId]]/+page.svelte | 525 +++++++++++++++--- .../src/routes/register/[[studyId]]/+page.ts | 17 +- frontend/src/routes/tutor/+layout.server.ts | 16 - .../src/routes/tutor/register/+page.server.ts | 52 -- .../src/routes/tutor/register/+page.svelte | 454 --------------- 18 files changed, 796 insertions(+), 722 deletions(-) create mode 100644 .DS_Store create mode 100644 backend/alembic/versions/9dfb49268c5f_add_my_slots_column_to_users_table.py create mode 100644 backend/alembic/versions/ce5a922c7fc4_add_new_columns_to_user_table.py create mode 100644 backend/languagelab.sqbpro delete mode 100644 frontend/src/routes/tutor/+layout.server.ts delete mode 100644 frontend/src/routes/tutor/register/+page.server.ts delete mode 100644 frontend/src/routes/tutor/register/+page.svelte diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..39ee3f3a5dfc0b2222c1f86429a3f770339027c4 GIT binary patch literal 6148 zcmeHKF>4e-6#m9+yi1gjbQTud*a|0DyM|K)6@;`F=I)X+m~)ThtZWvMG?sQYLa<Ya zDJ?{>uuM`}rxhFX0|I_;<|X&`_AZr($UK<&_U65r_w6^#n_B>G@mZ$<%mb*i2`01b z#+dk}RqULOTqZhMW4+s0YUjOvB{B{~1)>7~q5^Vu6I?|XYZ%z`JGXk}r}g#nX0x@P zw>Vb+`u4@$#?H5!Wq;>PfB*5YHq5<wz^SOAg=Mty1S@!oj?F%uy;`V7SPp~b!{;{w zhmm!9`H;!vD9dO1QxW*Ik(0?Pwdi8g@?nAd;rWyzET6r!IMJlWcQWf(K9i#?A0QPz z2|8S-J;KCY`)qxd!|v;M=kI;cebKo#`#66ouk-s-rve-P4F=VnncY3!V*W~u<?*t0 z@az6Z&tpv7aCgp%v%D^TIb=%ev4&n{XWY?s>$5y|_TS(BzNLETT&u@8zs-wq!_~0K zUD#muJ_aMZf$clYJ`!O$Je)Z_E0w6B5|g7!e4O0Vf6RDii`6d#jD1ufDi9SoRY2-P z$|jg876E<fVCM@YG2yT`KKmL)Ol5$WDHZ`aLUUSMolbT7iQ&R@`csX|6pMgiI$S8t zaGKfa7s^S6bg2)A%L2weDi9SYD=-y)&ddFOi)VPk_kVd({1p|53j9|JxTLw%T;wb1 z-CB7$xocClM{HseR|ND^*y(lbALJ^&#O95@R33<#ViAxdwD?EB%MiP$z+YA1H_`I# AWdHyG literal 0 HcmV?d00001 diff --git a/backend/alembic/versions/37f4cc82f93e_update_survey_response_with_other_.py b/backend/alembic/versions/37f4cc82f93e_update_survey_response_with_other_.py index 698b58cc..a5235544 100644 --- a/backend/alembic/versions/37f4cc82f93e_update_survey_response_with_other_.py +++ b/backend/alembic/versions/37f4cc82f93e_update_survey_response_with_other_.py @@ -1,7 +1,7 @@ """Update survey response with other languages Revision ID: 37f4cc82f93e -Revises: +Revises: Create Date: 2024-12-22 18:42:42.049100 """ diff --git a/backend/alembic/versions/9dfb49268c5f_add_my_slots_column_to_users_table.py b/backend/alembic/versions/9dfb49268c5f_add_my_slots_column_to_users_table.py new file mode 100644 index 00000000..6771b24a --- /dev/null +++ b/backend/alembic/versions/9dfb49268c5f_add_my_slots_column_to_users_table.py @@ -0,0 +1,29 @@ +"""Add my_slots column to users table + +Revision ID: 9dfb49268c5f +Revises: ce5a922c7fc4 +Create Date: 2025-02-23 18:48:24.958552 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "9dfb49268c5f" +down_revision: Union[str, None] = "ce5a922c7fc4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade(): + op.add_column( + "users", sa.Column("my_slots", sa.JSON, nullable=False, server_default="[]") + ) + + +def downgrade(): + op.drop_column("users", "my_slots") diff --git a/backend/alembic/versions/ce5a922c7fc4_add_new_columns_to_user_table.py b/backend/alembic/versions/ce5a922c7fc4_add_new_columns_to_user_table.py new file mode 100644 index 00000000..140eeb2d --- /dev/null +++ b/backend/alembic/versions/ce5a922c7fc4_add_new_columns_to_user_table.py @@ -0,0 +1,33 @@ +"""Add new columns to user table + +Revision ID: ce5a922c7fc4 +Revises: fe09c6f768cd +Create Date: 2025-02-23 12:01:52.379157 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "ce5a922c7fc4" +down_revision: Union[str, None] = "fe09c6f768cd" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade(): + op.add_column("users", sa.Column("availabilities", sa.JSON, default=[])) + op.add_column("users", sa.Column("tutor_list", sa.JSON, default=[])) + op.add_column("users", sa.Column("my_tutor", sa.String, default="")) + op.add_column("users", sa.Column("bio", sa.String, default="")) + + +def downgrade(): + op.drop_column("users", "availabilities") + op.drop_column("users", "tutor_list") + op.drop_column("users", "my_tutor") + op.drop_column("users", "bio") diff --git a/backend/app/models.py b/backend/app/models.py index 7385a449..2ae862f2 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -14,6 +14,7 @@ from enum import Enum from database import Base import datetime from utils import datetime_aware +from sqlalchemy.dialects.postgresql import JSON class UserType(Enum): @@ -40,7 +41,7 @@ class User(Base): password = Column(String) type = Column(Integer, default=UserType.STUDENT.value) is_active = Column(Boolean, default=True) - availability = Column(String, default=0) + bio = Column(String, default="") ui_language = Column(String, default="fr") home_language = Column(String, default="en") target_language = Column(String, default="fr") @@ -48,6 +49,10 @@ class User(Base): gender = Column(String, default=None) calcom_link = Column(String, default="") last_survey = Column(DateTime, default=None) + availabilities = Column(JSON, default=[]) + tutor_list = Column(JSON, default=[]) + my_tutor = Column(String, default="") + my_slots = Column(JSON, default=[]) sessions = relationship( "Session", secondary="user_sessions", back_populates="users" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index bfd43f91..ea40fd01 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -20,7 +20,7 @@ class User(BaseModel): email: str nickname: str type: int - availability: int + bio: str | None is_active: bool ui_language: str | None home_language: str | None @@ -29,6 +29,10 @@ class User(BaseModel): gender: str | None = None calcom_link: str | None last_survey: NaiveDatetime | None = None + availabilities: list[dict] | None = [] + tutor_list: list[str] | None = [] + my_tutor: str | None = None + my_slots: list[dict] | None = [] class Config: from_attributes = True @@ -53,7 +57,7 @@ class UserCreate(BaseModel): nickname: str | None = None password: str type: int = UserType.STUDENT.value - availability: int = 0 + bio: str | None = None is_active: bool = True ui_language: str | None = None home_language: str | None = None @@ -62,6 +66,10 @@ class UserCreate(BaseModel): gender: str | None = None calcom_link: str | None = None last_survey: NaiveDatetime | None = None + availabilities: list[dict] | None = [] + tutor_list: list[str] | None = [] + my_tutor: str | None = None + my_slots: list[dict] | None = [] class UserUpdate(BaseModel): @@ -69,7 +77,7 @@ class UserUpdate(BaseModel): nickname: str | None = None password: str | None = None type: int | None = None - availability: int | None = None + bio: str | None = None is_active: bool | None = None ui_language: str | None = None home_language: str | None = None @@ -78,6 +86,10 @@ class UserUpdate(BaseModel): gender: str | None = None calcom_link: str | None = None last_survey: NaiveDatetime | None = None + availabilities: list[dict] | None = [] + tutor_list: list[str] | None = [] + my_tutor: str | None = None + my_slots: list[dict] | None = None class Config: from_attributes = True diff --git a/backend/languagelab.sqbpro b/backend/languagelab.sqbpro new file mode 100644 index 00000000..9fd6373f --- /dev/null +++ b/backend/languagelab.sqbpro @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="languagelab.sqlite" readonly="0" foreign_keys="1" case_sensitive_like="0" temp_store="0" wal_autocheckpoint="1000" synchronous="2"/><attached/><window><main_tabs open="structure browser pragmas query" current="3"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="2614"/><column_width id="4" width="0"/><expanded_item id="0" parent="1"/><expanded_item id="1" parent="1"/><expanded_item id="2" parent="1"/><expanded_item id="3" parent="1"/></tab_structure><tab_browse><table title="contacts" custom_title="0" dock_id="2" table="4,8:maincontacts"/><dock_state state="000000ff00000000fd0000000100000002000005940000032bfc0100000002fb000000160064006f0063006b00420072006f00770073006500310100000000000005940000000000000000fb000000160064006f0063006b00420072006f00770073006500320100000000ffffffff0000010d00ffffff000002c80000000000000004000000040000000800000008fc00000000"/><default_encoding codec=""/><browse_table_settings/></tab_browse><tab_sql><sql name="SQL 1*">SELECT * FROM users;</sql><current_tab id="0"/></tab_sql></sqlb_project> diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index 9786ce90..8fa7b314 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -218,10 +218,36 @@ } }, "register": { + "confirm": "Confirm", + "bio": "Short Bio", + "bio.note": "Introduce yourself in one sentence", + "bio.ph": "Write something about yourself here...", "birthyear": "Year of birth", "birthyear.note": "In what year were you born?", "confirmPassword": "Confirm password", "confirmTutor": "Do you confirm selecting “{NAME}†as your guardian?", + "availabilities": "Availabilities", + "noAvailabilities": "No availabilities provided", + "notAvailable": "Not available", + "scheduleMeeting": "Schedule Meeting", + "linkNotAvailable": "Link Not Available", + "noTutorsAvailable": "No tutors are currently available", + "selectWeekday": "Select a weekday", + "weekday": "Weekday", + "startTime": "Start Time", + "endTime": "End Time", + "addAvailability": "Add Availability", + "yourAvailabilities": "Your Availabilities", + "remove": "Remove", + "loadingTutors": "Loading tutors...", + "firstLanguage": "First Language:", + "role": "Role", + "ScheduleWith": "Schedule a meeting with", + "roles": { + "note": "Select the role you want to play in the application", + "learner": "Learner", + "tutor": "Tutor" + }, "consent": { "intro": "You are invited to participate in a scientific study. \nThe objective of this study is to understand how tutors and foreign language learners interact during online tutoring sessions. \nThe data collected will be used to improve online tutoring tools and to better understand cognitive processes on both sides.", "ok": "I agree to participate in the study as described above.", @@ -291,9 +317,12 @@ "availabilities": "Availability", "consent": "Consent", "continue": "Continue", + "timeslots": "tutor selection", + "availableSlots": "Available slots :", "information": "Information", "signup": "Registration", - "start": "To start" + "start": "To start", + "ScheduleWith": "Schedule a meeting with" }, "welcome": "Welcome to LanguageLab! Before you begin, please fill out the following information. This will allow us to get to know you better and tailor the experience to your needs.", "homeLanguage": "First language", @@ -373,7 +402,8 @@ "calcomWarning": "Invalid cal.com link", "cesttime": "CET time (Brussels)", "noTutors": "No tutor available. \nPlease select other availabilities.", - "setAvailabilities": "Select your availability" + "setAvailabilities": "Select your availability", + "availableSlots": "Available slots" }, "users": { "actions": "Actions", diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index 11852e24..efeb4f3b 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -119,6 +119,10 @@ "passwordPrompt": "Entrez le mot de passe. Attention, celui-ci ne sera plus récupérable." }, "register": { + "confirm": "Confirmer", + "bio": "Courte biographie", + "bio.note": "Présentez-vous en une phrase", + "bio.ph": "Écrivez quelque chose sur vous-même ici...", "email": "E-mail", "email.ph": "Adresse e-mail", "email.note": "Votre adresse e-mail sera utilisée pour vous connecter à l'application", @@ -130,16 +134,40 @@ "confirmPassword": "Confirmer le mot de passe", "humans": "Je ne suis pas un robot", "signup": "S'inscrire", + "availabilities": "Disponibilités", + "noAvailabilities": "Aucune disponibilité fournie", + "notAvailable": "Non disponible", + "scheduleMeeting": "Planifier une réunion", + "linkNotAvailable": "Lien non disponible", + "noTutorsAvailable": "Aucun tuteur n'est actuellement disponible", + "selectWeekday": "Sélectionnez un jour de la semaine", + "weekday": "Jour de la semaine", + "startTime": "Heure de début", + "endTime": "Heure de fin", + "addAvailability": "Ajouter disponibilité", + "yourAvailabilities": "Vos disponibilités", + "remove": "Supprimer", + "loadingTutors": "Chargement des tuteurs...", + "firstLanguage": "Langue principale :", + "role": "Rôle", + "ScheduleWith": "Planifier une réunion avec", + "roles": { + "note": "Sélectionnez le rôle que vous souhaitez jouer dans l'application", + "learner": "Apprenant", + "tutor": "Tuteur" + }, "tab": { "study": "Étude", "consent": "Consentement", "signup": "Inscription", "information": "Informations", "test": "Tests", - "timeslots": "Tuteur", + "timeslots": "Choix tuteur", + "availableSlots": "Créneaux disponibles :", "start": "Commencer", "continue": "Continuer", - "availabilities": "Disponibilitées" + "availabilities": "Disponibilitées", + "ScheduleWith": "Planifier une réunion avec" }, "error": { "emptyFields": "Veuillez remplir tous les champs", @@ -234,7 +262,8 @@ "noTutors": "Aucun tuteur disponible. Veuillez sélectionner d'autres disponibilités.", "setAvailabilities": "Sélectionnez vos disponibilités", "calcom": "Lien vers la réservation du calendrier", - "calcomWarning": "Lien cal.com invalide" + "calcomWarning": "Lien cal.com invalide", + "availableSlots": "Créneaux disponibles" }, "tests": { "sendResults": "Envoyer", diff --git a/frontend/src/lang/nl.json b/frontend/src/lang/nl.json index 08d8434c..29c931f4 100644 --- a/frontend/src/lang/nl.json +++ b/frontend/src/lang/nl.json @@ -88,8 +88,15 @@ "noAccountLink": "Registreer hier" }, "register": { + "confirm": "Bevestigen", "confirmPassword": "Bevestig wachtwoord", "confirmTutor": "Bevestig je dat je '{NAME}' als je voogd hebt geselecteerd?", + "role": "Rol", + "roles": { + "note": "Selecteer de rol die u wilt spelen in de applicatie", + "learner": "Leerling", + "tutor": "Tutor" + }, "consent": { "intro": "U wordt uitgenodigd om deel te nemen aan een wetenschappelijk onderzoek. \nHet doel van deze studie is om te begrijpen hoe docenten en leerlingen in vreemde talen communiceren tijdens online tutorssessies. \nDe verzamelde gegevens zullen worden gebruikt om online tutoring -tools te verbeteren en om de cognitieve processen aan beide zijden beter te begrijpen.", "ok": "Ik ga akkoord met deelname aan het onderzoek zoals hierboven beschreven.", diff --git a/frontend/src/lib/types/user.ts b/frontend/src/lib/types/user.ts index b05a1d01..b1acf04e 100644 --- a/frontend/src/lib/types/user.ts +++ b/frontend/src/lib/types/user.ts @@ -30,12 +30,17 @@ export default class User { private _target_language: string | null; private _birthdate: Date | null; private _gender: string | null; + private _bio: string | null; private _calcom_link: string | null; private _study_id: number | null; private _last_survey: Date | null; + private _tutor_list: string[]; + private _my_tutor: string | null; private _ws_connected: boolean = false; private _ws: WebSocket | null = null; private _sessions_added: Writable<Session[]> = writable([]); + private _availabilities: { day: string; start: string; end: string }[]; + private _my_slots: { day: string; start: string; end: string }[]; private constructor( id: number, @@ -50,7 +55,12 @@ export default class User { gender: string | null, calcom_link: string | null, study_id: number | null, - last_survey: Date | null + last_survey: Date | null, + tutor_list: string[] = [], + my_tutor: string | null = null, + bio: string | null = null, + availabilities: { day: string; start: string; end: string }[] = [], + my_slots: { day: string; start: string; end: string }[] = [] ) { this._id = id; this._email = email; @@ -65,6 +75,11 @@ export default class User { this._calcom_link = calcom_link; this._study_id = study_id; this._last_survey = last_survey; + this._tutor_list = tutor_list; + this._my_tutor = my_tutor; + this._bio = bio; + this._availabilities = availabilities; + this._my_slots = my_slots; } get id(): number { @@ -87,6 +102,10 @@ export default class User { return this._type; } + get bio(): string | null { + return this._bio; + } + get is_active(): boolean { return this._is_active; } @@ -135,6 +154,34 @@ export default class User { return this._sessions_added; } + get my_tutor(): string | null { + return this._my_tutor; + } + + get tutor_list(): string[] { + return this._tutor_list; + } + + get availabilities(): { day: string; start: string; end: string }[] { + return this._availabilities; + } + + set availabilities(value: { day: string; start: string; end: string }[]) { + this._availabilities = value; + } + + get my_slots(): { day: string; start: string; end: string }[] { + return this._my_slots; + } + + set my_slots(value: { day: string; start: string; end: string }[]) { + this._my_slots = value; + } + + set tutor_list(value: string[]) { + this._tutor_list = value; + } + equals<T>(obj: T): boolean { if (obj === null || obj === undefined) return false; if (!(obj instanceof User)) return false; @@ -168,7 +215,12 @@ export default class User { gender: this.gender, calcom_link: this.calcom_link, study_id: this.study_id, - last_survey: this.last_survey + last_survey: this.last_survey, + tutor_list: this._tutor_list, + my_tutor: this.my_tutor, + bio: this.bio, + availabilities: this._availabilities, + my_slots: this._my_slots }); } @@ -187,6 +239,11 @@ export default class User { if (data.calcum_link) this._calcom_link = data.calcom_link; if (data.study_id) this._study_id = data.study_id; if (data.last_survey) this._last_survey = data.last_survey; + if (data.tutor_list) this._tutor_list = data.tutor_list; + if (data.my_tutor) this._my_tutor = data.my_tutor; + if (data.bio) this._bio = data.bio; + if (data.availabilities) this._availabilities = data.availabilities; + if (data.my_slots) this._my_slots = data.my_slots; } return res; } @@ -218,7 +275,12 @@ export default class User { null, null, null, - null + null, + [], + null, + null, + [], + [] ); users.add(user); return user; @@ -294,7 +356,12 @@ export default class User { json.gender, json.calcom_link, json.study_id, - json.last_survey === null ? null : parseToLocalDate(json.last_survey) + json.last_survey === null ? null : parseToLocalDate(json.last_survey), + json.tutor_list || [], + json.my_tutor, + json.bio, + json.availabilities || [], + json.my_slots || [] ); users.update((us) => { diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 71f9cd12..963b4208 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -23,7 +23,6 @@ let { data } = $props(); let user = data.user!; - let jwt = data.jwt!; let contacts: User[] = $state(data.contacts); let contact: User | undefined = $state(data.contact); let contactSessions: Session[] = $state(data.sessions); @@ -33,17 +32,6 @@ let showTerminatedSessions = $state(false); - user.sessions_added.subscribe((sessions) => { - if (!contact) return; - - sessions = sessions.filter((s) => s.users.some((u) => u.id === contact?.id)); - - contactSessions = [ - ...contactSessions, - ...sessions.filter((s) => !contactSessions.some((cs) => cs.id === s.id)) - ].sort((a, b) => b.start_time.getTime() - a.start_time.getTime()); - }); - async function selectContact(c: User | undefined) { showTerminatedSessions = false; contact = c; @@ -58,71 +46,77 @@ } onMount(async () => { - user.wsConnect(jwt); + contacts = User.parseAll(await getUserContactsAPI(fetch, user.id)); + + if (contacts.length === 0 && user.my_tutor) { + const res = await createUserContactFromEmailAPI(fetch, user.id, user.my_tutor); + if (res) { + contacts = User.parseAll(await getUserContactsAPI(fetch, user.id)); + } + } + + if (user.my_slots && user.my_slots.length > 0) { + const firstSlot = user.my_slots[0]; + + let sessionDate = new Date(); + + while ( + sessionDate.toLocaleString('en-US', { weekday: 'long' }).toLowerCase() !== firstSlot.day + ) { + sessionDate.setDate(sessionDate.getDate() + 1); + } + + const [startHour, startMinute] = firstSlot.start.split(':').map(Number); + const [endHour, endMinute] = firstSlot.end.split(':').map(Number); - (function (C: any, A: any, L: any) { - let p = function (a: any, ar: any) { - a.q.push(ar); - }; - let d = C.document; - C.Cal = - C.Cal || - function () { - let cal = C.Cal; - let ar = arguments; - if (!cal.loaded) { - cal.ns = {}; - cal.q = cal.q || []; - d.head.appendChild(d.createElement('script')).src = A; - cal.loaded = true; - } - if (ar[0] === L) { - const api: any = function () { - p(api, arguments); - }; - const namespace = ar[1]; - api.q = api.q || []; - if (typeof namespace === 'string') { - cal.ns[namespace] = cal.ns[namespace] || api; - p(cal.ns[namespace], ar); - p(cal, ['initNamespace', namespace]); - } else p(cal, ar); - return; - } - p(cal, ar); - }; - })(window, 'https://app.cal.com/embed/embed.js', 'init'); - // @ts-ignore - Cal('init'); - // @ts-ignore - Cal('on', { - action: 'bookingSuccessful', - callback: async (e: any) => { - if (!contact || !user || !e.detail.data) { - toastAlert(get(t)('home.bookingFailed')); - return; + sessionDate.setHours(startHour, startMinute, 0, 0); + + if (!contact) { + console.warn('No contact selected, cannot create a session.'); + return; + } + + contactSessions = Session.parseAll( + await getUserContactSessionsAPI(fetch, user.id, contact.id) + ); + + for (let i = 0; i < 8; i++) { + let sessionStartDate = new Date(sessionDate); + sessionStartDate.setDate(sessionStartDate.getDate() + 7 * i); + + let sessionEndDate = new Date(sessionStartDate); + sessionEndDate.setHours(endHour, endMinute, 0, 0); + + const sessionExists = contactSessions.some( + (s) => + s.start_time.getTime() === sessionStartDate.getTime() && + s.end_time.getTime() === sessionEndDate.getTime() + ); + + if (sessionExists) { + continue; } - const date = new Date(e.detail.data.date); - const duration = e.detail.data.duration; - const end = new Date(date.getTime() + duration * 60000); const sess_id: number | null = await createSessionFromCalComAPI( fetch, user.id, contact.id, - date, - end + sessionStartDate, + sessionEndDate ); + if (!sess_id) { - toastAlert(get(t)('home.bookingFailed')); - return; + console.warn(`Failed to create session for ${sessionStartDate}`); + continue; } - toastSuccess(get(t)('home.bookingSuccessful')); + contactSessions = Session.parseAll( - await getUserContactSessionsAPI(fetch, user!.id, contact.id) + await getUserContactSessionsAPI(fetch, user.id, contact.id) ).sort((a, b) => b.start_time.getTime() - a.start_time.getTime()); } - }); + + toastSuccess(get(t)('home.bookingSuccessful')); + } }); async function createSession() { @@ -299,7 +293,7 @@ </table> </div> </div> - {:else} + <!-- {:else} <div class="flex-grow text-center mt-16"> <div class="text-lg text-gray-500 pt-4 italic">{$t('home.noContact')}</div> <div> @@ -307,7 +301,7 @@ + {$t('home.newFirstContact')} </button> </div> - </div> + </div> --> {/if} </div> diff --git a/frontend/src/routes/register/[[studyId]]/+page.server.ts b/frontend/src/routes/register/[[studyId]]/+page.server.ts index b4e4ee04..1e911c77 100644 --- a/frontend/src/routes/register/[[studyId]]/+page.server.ts +++ b/frontend/src/routes/register/[[studyId]]/+page.server.ts @@ -1,5 +1,5 @@ import { addUserToStudyAPI } from '$lib/api/studies'; -import { patchUserAPI } from '$lib/api/users'; +import { patchUserAPI, getUsersAPI } from '$lib/api/users'; import { formatToUTCDate } from '$lib/utils/date'; import { validateEmail, validatePassword, validateUsername } from '$lib/utils/security'; import { redirect, type Actions } from '@sveltejs/kit'; @@ -14,6 +14,7 @@ export const actions: Actions = { const nickname = formData.get('nickname'); const password = formData.get('password'); const confirmPassword = formData.get('confirmPassword'); + const role = formData.get('role'); if (!email || !nickname || !password || !confirmPassword) { return { message: 'Invalid request' }; @@ -25,12 +26,14 @@ export const actions: Actions = { if (password !== confirmPassword) return { message: 'Passwords do not match' }; + const is_tutor = Number(role) === 1; + let response = await fetch(`/api/auth/register`, { headers: { 'Content-Type': 'application/json' }, method: 'POST', - body: JSON.stringify({ email, nickname, password, is_tutor: false }) + body: JSON.stringify({ email, nickname, password, is_tutor }) }); if (response.status === 400) return { message: 'User already exists' }; @@ -64,36 +67,56 @@ export const actions: Actions = { const birthyear = formData.get('birthyear'); const gender = formData.get('gender'); const study = formData.get('study'); - - if (!homeLanguage || !targetLanguage || !birthyear || !gender || !study) { - return { message: 'Invalid request' }; + const bio = formData.get('bio'); + let my_tutor = formData.get('myTutor'); + + if (locals.user.type == 2) { + if (!homeLanguage || !targetLanguage || !birthyear || !gender) { + return { message: 'Invalid request' }; + } + // Fixme: I struggled to retrieve the my_tutor's value in the form (temporary fix) + if (!my_tutor || (my_tutor as string).trim() === '') { + my_tutor = ''; + } + + let birthdate; + try { + birthdate = formatToUTCDate(new Date(parseInt(birthyear.toString()), 0, 30)); + } catch (e) { + return { message: 'Invalid request' }; + } + + const response = await patchUserAPI(fetch, locals.user.id, { + home_language: homeLanguage, + target_language: targetLanguage, + gender, + birthdate, + my_tutor: my_tutor + }); + if (!response) return { message: 'Unknown error occurred' }; + + redirect(303, '/register'); + } else if (locals.user.type == 1) { + if (!homeLanguage || !birthyear || !gender || !bio) { + return { message: 'Invalid request' }; + } + + let birthdate; + try { + birthdate = formatToUTCDate(new Date(parseInt(birthyear.toString()), 0, 30)); + } catch (e) { + return { message: 'Invalid request' }; + } + + let response = await patchUserAPI(fetch, locals.user.id, { + home_language: homeLanguage, + target_language: targetLanguage, + gender, + birthdate, + bio + }); + if (!response) return { message: 'Unknown error occurred' }; + redirect(303, '/register'); } - - let birthdate; - try { - birthdate = formatToUTCDate(new Date(parseInt(birthyear.toString()), 0, 30)); - } catch (e) { - return { message: 'Invalid request' }; - } - - let studyId; - try { - studyId = parseInt(study.toString()); - } catch (e) { - return { message: 'Invalid request' }; - } - - let response = await patchUserAPI(fetch, locals.user.id, { - home_language: homeLanguage, - target_language: targetLanguage, - gender, - birthdate - }); - if (!response) return { message: 'Unknown error occurred' }; - - response = await addUserToStudyAPI(fetch, studyId, locals.user.id); - if (!response) return { message: 'Failed to add user to study' }; - - redirect(303, '/register'); } }; diff --git a/frontend/src/routes/register/[[studyId]]/+page.svelte b/frontend/src/routes/register/[[studyId]]/+page.svelte index 199555a3..85ae010c 100644 --- a/frontend/src/routes/register/[[studyId]]/+page.svelte +++ b/frontend/src/routes/register/[[studyId]]/+page.svelte @@ -1,12 +1,14 @@ <script lang="ts"> import config from '$lib/config'; + import { patchUserAPI } from '$lib/api/users'; + import { displayDate } from '$lib/utils/date'; import { t } from '$lib/services/i18n'; import { Icon, Envelope, Key, UserCircle } from 'svelte-hero-icons'; import Typingtest from '$lib/components/tests/typingtest.svelte'; + import { browser } from '$app/environment'; import type { PageData } from './$types'; - import Consent from '$lib/components/surveys/consent.svelte'; import type Study from '$lib/types/study'; - import { displayDate } from '$lib/utils/date'; + import Consent from '$lib/components/surveys/consent.svelte'; let { data, form }: { data: PageData; form: FormData } = $props(); let study: Study | undefined = $state(data.study); @@ -16,19 +18,135 @@ let selectedStudy: Study | undefined = $state(); + let tutors = $state(data.tutors || []); + console.log('data:', data); + let isLoading = $state(false); + let selectedTutorEmail = $state(''); + let is_tutor = $state(false); + const MAX_BIO_LENGTH = 100; + let remainingCharacters = $state(MAX_BIO_LENGTH); + let bio = $state(''); + + type Availability = { + day: string; + start: string; + end: string; + }; + + let selectedWeekday: string = $state(''); + let selectedTimeStart: string = $state(''); + let selectedTimeEnd: string = $state(''); + + const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + let availability: Availability[] = $state([]); + let showSchedulePopup = $state(false); + let selectedTutor: any = $state(null); + let selectedSlot: Availability | null = $state(null); + let current_step = $state( (() => { if (user == null) { if (form?.message) return 3; if (study) return 2; return 1; - } else if (!user.home_language || !user.target_language || !user.birthdate || !user.gender) { - return 4; - } else { - return 6; + } else if (user.type == 1) + if (!user.home_language || !user.birthdate || !user.gender || !user.bio) { + return 4; + } else if (user.bio) { + return 6; + } else { + return 8; + } + else if (user.type == 2) { + if (!user.home_language || !user.target_language || !user.birthdate || !user.gender) { + return 4; + } else if (tutors.length > 0) { + return 5; + } else if (!user.my_tutor) { + return 7; + } else { + return 8; + } } + return 1; })() ); + + let study_id: number | null = (() => { + if (!browser) return null; + let study_id_str = new URLSearchParams(window.location.search).get('study'); + if (!study_id_str) return null; + return parseInt(study_id_str) || null; + })(); + + async function handleTutorSelection(tutor: any) { + selectedTutorEmail = tutor.email; + selectedTutor = tutor; + showSchedulePopup = true; + } + + async function confirmMeeting() { + if (!selectedSlot) return; + + const updatedSlots = [...(user.my_slots || []), selectedSlot]; + selectedTutorEmail = selectedTutor.email; + showSchedulePopup = false; + + if (user?.id) { + try { + // Fixme: this should be moved to the server side + const success = await patchUserAPI(fetch, user.id, { + my_tutor: selectedTutorEmail, + my_slots: updatedSlots + }); + if (success) current_step += 2; + } catch (error) { + console.error('Error:', error); + } + } + } + + function handleBioInput(event: Event) { + const target = event.target as HTMLTextAreaElement; + + if (target.value.length > MAX_BIO_LENGTH) { + target.value = target.value.slice(0, MAX_BIO_LENGTH); + } + + bio = target.value; + remainingCharacters = MAX_BIO_LENGTH - bio.length; + } + function addAvailability(): void { + if (selectedWeekday && selectedTimeStart && selectedTimeEnd) { + availability.push({ + day: selectedWeekday, + start: selectedTimeStart, + end: selectedTimeEnd + }); + selectedWeekday = ''; + selectedTimeStart = ''; + selectedTimeEnd = ''; + } else { + console.error('Failed to add availability. Make sure all fields are selected.'); + } + } + + function removeAvailability(index: number): void { + availability.splice(index, 1); + } + + async function onAvailabilities() { + let res; + if (user && user.id) { + res = await patchUserAPI(fetch, user.id, { + availabilities: availability + }); + } + + if (!res) return; + + current_step++; + } </script> <div class="header mx-auto my-5"> @@ -47,6 +165,7 @@ {$t('register.tab.consent')} {/if} </li> + <li class="step" class:step-primary={current_step >= 3}> {$t('register.tab.signup')} </li> @@ -56,13 +175,16 @@ <li class="step" class:step-primary={current_step >= 5}> {$t('register.tab.timeslots')} </li> - <li class="step" class:step-primary={current_step >= 6} data-content="?"> + <li class="step" class:step-primary={current_step >= 6}> + {$t('register.tab.availabilities')} + </li> + <li class="step" class:step-primary={current_step >= 7} data-content="?"> {$t('register.tab.continue')} </li> - <li class="step" class:step-primary={current_step >= 7} data-content=""> + <li class="step" class:step-primary={current_step >= 8} data-content=""> {$t('register.tab.test')} </li> - <li class="step" class:step-primary={current_step >= 8} data-content="★"> + <li class="step" class:step-primary={current_step >= 9} data-content="★"> {$t('register.tab.start')} </li> </ul> @@ -118,7 +240,9 @@ {$t('button.continue')} </a> </div> - {:else if current_step == 2} + {/if} + + {#if current_step == 2} <Consent introText={$t('register.consent.intro')} participation={$t('register.consent.participation')} @@ -195,90 +319,289 @@ /> </div> </label> + <div class="form-control"> + <label for="role" class="label"> + <span class="label-text">{$t('register.role')}</span> + </label> + <select class="select select-bordered" id="role" name="role" bind:value={is_tutor}> + <option value="2">{$t('register.roles.learner')}</option> + <option value="1">{$t('register.roles.tutor')}</option> + </select> + </div> <div class="form-control"> <button class="button mt-2">{$t('register.signup')}</button> </div> </form> </div> {:else if current_step == 4} - <form class="space-y-2" method="POST" action="?/data"> - <div class="p-5 text-sm text-prose"> - {@html $t('register.welcome')} - </div> - <div class="form-control"> - <label for="homeLanguage" class="label"> - <span class="label-text">{$t('register.homeLanguage')}</span> - <span class="label-text-alt">{$t('register.homeLanguage.note')}</span> - </label> - <select class="select select-bordered" id="homeLanguage" name="homeLanguage" required> - <option disabled selected value="">{$t('register.homeLanguage')}</option> - {#each Object.entries(config.PRIMARY_LANGUAGE) as [code, name]} - <option value={code}>{name}</option> - {/each} - </select> - </div> - <div class="form-control"> - <label for="targetLanguage" class="label"> - <span class="label-text">{$t('register.targetLanguage')}</span> - <span class="label-text-alt">{$t('register.targetLanguage.note')}</span> - </label> - <select class="select select-bordered" id="targetLanguage" name="targetLanguage" required> - {#each Object.entries(config.LEARNING_LANGUAGES) as [code, name]} - <option value={code}>{name}</option> - {/each} - </select> - </div> - <div class="form-control"> - <label for="birthyear" class="label"> - <span class="label-text">{$t('register.birthyear')}</span> - <span class="label-text-alt">{$t('register.birthyear.note')}</span> - </label> - <select class="select select-bordered" id="birthyear" name="birthyear" required> - <option disabled selected value="">{$t('register.birthyear')}</option> - {#each Array.from({ length: 90 }, (_, i) => i + 1931).reverse() as year} - <option value={year}>{year}</option> - {/each} - </select> - </div> - <div class="form-control space-y-1"> - <label for="gender" class="label"> - <span class="label-text">{$t('register.gender')}</span> - <span class="label-text-alt">{$t('register.gender.note')}</span> - </label> - <div class="label justify-normal gap-2 py-0"> - <input type="radio" class="radio" id="male" name="gender" value="male" required /> - <label for="male" class="label-text cursor-pointer"> - {$t('register.genders.male')} + {#if user && user.type === 2} + <form class="space-y-2" method="POST" action="?/data"> + <div class="p-5 text-sm text-prose"> + {@html $t('register.welcome')} + </div> + <div class="form-control"> + <label for="homeLanguage" class="label"> + <span class="label-text">{$t('register.homeLanguage')}</span> + <span class="label-text-alt">{$t('register.homeLanguage.note')}</span> </label> + <select class="select select-bordered" id="homeLanguage" name="homeLanguage" required> + <option disabled selected value="">{$t('register.homeLanguage')}</option> + {#each Object.entries(config.PRIMARY_LANGUAGE) as [code, name]} + <option value={code}>{name}</option> + {/each} + </select> </div> - <div class="label justify-normal gap-2 py-0"> - <input type="radio" class="radio" id="female" name="gender" value="female" required /> - <label for="female" class="label-text cursor-pointer"> - {$t('register.genders.female')} + <div class="form-control"> + <label for="targetLanguage" class="label"> + <span class="label-text">{$t('register.targetLanguage')}</span> + <span class="label-text-alt">{$t('register.targetLanguage.note')}</span> </label> + <select class="select select-bordered" id="targetLanguage" name="targetLanguage" required> + {#each Object.entries(config.LEARNING_LANGUAGES) as [code, name]} + <option value={code}>{name}</option> + {/each} + </select> </div> - <div class="label justify-normal gap-2 py-0"> - <input type="radio" class="radio" id="other" name="gender" value="other" required /> - <label for="other" class="label-text cursor-pointer"> - {$t('register.genders.other')} + <div class="form-control"> + <label for="birthyear" class="label"> + <span class="label-text">{$t('register.birthyear')}</span> + <span class="label-text-alt">{$t('register.birthyear.note')}</span> </label> + <select class="select select-bordered" id="birthyear" name="birthyear" required> + <option disabled selected value="">{$t('register.birthyear')}</option> + {#each Array.from({ length: 90 }, (_, i) => i + 1931).reverse() as year} + <option value={year}>{year}</option> + {/each} + </select> </div> - <div class="label justify-normal gap-2 py-0"> - <input type="radio" class="radio" id="na" name="gender" value="na" required /> - <label for="na" class="label-text cursor-pointer"> - {$t('register.genders.na')} + <div class="form-control space-y-1"> + <label for="gender" class="label"> + <span class="label-text">{$t('register.gender')}</span> + <span class="label-text-alt">{$t('register.gender.note')}</span> </label> + <div class="label justify-normal gap-2 py-0"> + <input type="radio" class="radio" id="male" name="gender" value="male" required /> + <label for="male" class="label-text cursor-pointer"> + {$t('register.genders.male')} + </label> + </div> + <div class="label justify-normal gap-2 py-0"> + <input type="radio" class="radio" id="female" name="gender" value="female" required /> + <label for="female" class="label-text cursor-pointer"> + {$t('register.genders.female')} + </label> + </div> + <div class="label justify-normal gap-2 py-0"> + <input type="radio" class="radio" id="other" name="gender" value="other" required /> + <label for="other" class="label-text cursor-pointer"> + {$t('register.genders.other')} + </label> + </div> + <div class="label justify-normal gap-2 py-0"> + <input type="radio" class="radio" id="na" name="gender" value="na" required /> + <label for="na" class="label-text cursor-pointer"> + {$t('register.genders.na')} + </label> + </div> </div> - </div> - <input type="hidden" id="study" name="study" value={study?.id} /> - <div class="form-control"> - <button class="button mt-4">{$t('button.submit')}</button> - </div> - </form> + <div class="form-control"> + <button class="button mt-4">{$t('button.submit')}</button> + </div> + </form> + {:else} + <form class="space-y-2" method="POST" action="?/data"> + <div class="p-5 text-sm text-prose"> + {@html $t('register.welcome')} + </div> + <div class="form-control"> + <label for="homeLanguage" class="label"> + <span class="label-text">{$t('register.homeLanguage')}</span> + <span class="label-text-alt">{$t('register.homeLanguage.note')}</span> + </label> + <select class="select select-bordered" id="homeLanguage" name="homeLanguage" required> + <option disabled selected value="">{$t('register.homeLanguage')}</option> + {#each Object.entries(config.PRIMARY_LANGUAGE) as [code, name]} + <option value={code}>{name}</option> + {/each} + </select> + </div> + <div class="form-control"> + <label for="birthyear" class="label"> + <span class="label-text">{$t('register.birthyear')}</span> + <span class="label-text-alt">{$t('register.birthyear.note')}</span> + </label> + <select class="select select-bordered" id="birthyear" name="birthyear" required> + <option disabled selected value="">{$t('register.birthyear')}</option> + {#each Array.from({ length: 90 }, (_, i) => i + 1931).reverse() as year} + <option value={year}>{year}</option> + {/each} + </select> + </div> + <div class="form-control space-y-1"> + <label for="gender" class="label"> + <span class="label-text">{$t('register.gender')}</span> + <span class="label-text-alt">{$t('register.gender.note')}</span> + </label> + <div class="label justify-normal gap-2 py-0"> + <input type="radio" class="radio" id="male" name="gender" value="male" required /> + <label for="male" class="label-text cursor-pointer"> + {$t('register.genders.male')} + </label> + </div> + <div class="label justify-normal gap-2 py-0"> + <input type="radio" class="radio" id="female" name="gender" value="female" required /> + <label for="female" class="label-text cursor-pointer"> + {$t('register.genders.female')} + </label> + </div> + <div class="label justify-normal gap-2 py-0"> + <input type="radio" class="radio" id="other" name="gender" value="other" required /> + <label for="other" class="label-text cursor-pointer"> + {$t('register.genders.other')} + </label> + </div> + <div class="label justify-normal gap-2 py-0"> + <input type="radio" class="radio" id="na" name="gender" value="na" required /> + <label for="na" class="label-text cursor-pointer"> + {$t('register.genders.na')} + </label> + </div> + </div> + <div class="form-control"> + <label for="bio" class="label"> + <span class="label-text">{$t('register.bio')}</span> + <span class="label-text-alt">{remainingCharacters} / {MAX_BIO_LENGTH}</span> + </label> + <textarea + id="bio" + name="bio" + class="textarea textarea-bordered" + placeholder={$t('register.bio.note')} + rows="3" + required + oninput={handleBioInput} + bind:value={bio} + ></textarea> + </div> + <div class="form-control"> + <button class="button mt-4">{$t('button.submit')}</button> + </div> + </form> + {/if} {:else if current_step == 5} - <h2 class="my-4 text-xl">This page is disabled. Please continue.</h2> - <button onclick={() => current_step++}>{$t('button.continue')}</button> + {#if isLoading} + <p>{$t('register.loadingTutors')}</p> + {:else if tutors && tutors.length > 0} + <div class="max-w-4xl mx-auto"> + <ul class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 p-6 bg-gray-100"> + {#each tutors as tutor} + <li + class="card shadow-lg bg-white border border-gray-200 rounded-lg hover:shadow-xl transition-shadow w-full max-w-[300px] mx-auto" + > + <div class="card-body flex flex-col items-center text-center p-4"> + <div class="avatar placeholder"> + <div + class="bg-neutral-focus text-neutral-content rounded-full w-16 h-16 flex items-center justify-center mb-4" + > + {#if tutor.gender === 'female'} + <span class="text-2xl">👩</span> + {:else if tutor.gender === 'male'} + <span class="text-2xl">👨</span> + {:else} + <span class="text-2xl">👤</span> + {/if} + </div> + </div> + <h3 class="card-title text-lg font-bold text-gray-800 break-all"> + {tutor.nickname} + </h3> + <p class="text-sm text-gray-600 break-all">{tutor.email}</p> + <p class="text-sm text-gray-600"> + {$t('register.firstLanguage')} + {tutor.home_language || 'Not specified'} + </p> + <p class="text-xs text-gray-500 mt-1">{tutor.bio || 'No bio available.'}</p> + {#if tutor.availabilities?.length > 0} + <p class="text-sm text-gray-800 mt-2">{$t('register.availabilities')}</p> + <ul class="text-xs text-gray-500"> + {#each tutor.availabilities as availability} + <li>{availability.day}: {availability.start} - {availability.end}</li> + {/each} + </ul> + {:else} + <p class="text-xs text-gray-500 mt-1">{$t('register.noAvailabilities')}</p> + {/if} + {#if tutor.availabilities?.length > 0} + <button + class="btn btn-primary mt-4 text-white" + onclick={() => handleTutorSelection(tutor)} + onkeydown={(e) => e.key === 'Enter' && handleTutorSelection(tutor)} + > + {$t('register.scheduleMeeting')} + </button> + {:else} + <button class="btn btn-disabled mt-4">{$t('register.notAvailable')} </button> + {/if} + </div> + </li> + {/each} + </ul> + </div> + {:else} + <p>{$t('register.noTutorsAvailable')}</p> + {/if} {:else if current_step == 6} + <h2 class="my-4 text-xl">{$t('timeslots.setAvailabilities')}</h2> + <div class="form-control mt-4"> + <select id="weekday" bind:value={selectedWeekday} class="select select-bordered w-full"> + <option disabled value="">{$t('register.weekday')}</option> + {#each days as dayKey} + <option value={dayKey}>{$t(`utils.days.${dayKey}`)}</option> + {/each} + </select> + </div> + + <div class="form-control mt-4"> + <select id="timeStart" bind:value={selectedTimeStart} class="select select-bordered w-full"> + <option disabled value="">{$t('register.startTime')}</option> + {#each Array.from({ length: 24 }, (_, i) => `${i}:00`) as time} + <option value={time}>{time}</option> + {/each} + </select> + </div> + + <div class="form-control mt-4"> + <select id="timeEnd" bind:value={selectedTimeEnd} class="select select-bordered w-full"> + <option disabled value="">{$t('register.endTime')}</option> + {#each Array.from({ length: 24 }, (_, i) => `${i}:00`) as time} + <option value={time}>{time}</option> + {/each} + </select> + </div> + + <div class="form-control mt-4"> + <button class="button" onclick={addAvailability}>{$t('register.addAvailability')}</button> + </div> + + {#if availability.length > 0} + <h3 class="mt-4 text-lg">{$t('register.yourAvailabilities')}</h3> + <ul class="list-disc pl-6"> + {#each availability as { day, start, end }, index} + <li class="flex justify-between items-center"> + <span>{day}: {start} - {end}</span> + <button onclick={() => removeAvailability(index)} class="text-red-500 ml-4"> + {$t('register.remove')} + </button> + </li> + {/each} + </ul> + {/if} + <div class="form-control"> + <form onsubmit={onAvailabilities}> + <button type="submit" class="button mt-4">{$t('button.submit')}</button> + </form> + </div> + {:else if current_step == 7} <div class="text-center"> <p class="text-center"> {@html $t('register.continue')} @@ -290,11 +613,11 @@ {$t('register.startFastButton')} </button> </div> - {:else if current_step == 7} + {:else if current_step == 8} {#if user} <Typingtest onFinish={() => current_step++} {user} /> {/if} - {:else if current_step == 8} + {:else if current_step == 9} <div class="text-center"> <p class="text-center"> {@html $t('register.start')} @@ -304,6 +627,48 @@ </button> </div> {/if} + {#if showSchedulePopup} + <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> + <div class="bg-white rounded-lg p-6 max-w-sm w-full"> + <h3 class="text-lg font-bold mb-4"> + {$t('register.ScheduleWith')} + {selectedTutor.nickname} + </h3> + + {#if selectedTutor.availabilities?.length > 0} + <p class="mb-2">{$t('register.tab.availableSlots')}</p> + <ul class="space-y-2"> + {#each selectedTutor.availabilities as availability (availability.day)} + <button + type="button" + class="flex justify-between items-center p-2 rounded cursor-pointer transition-colors {selectedSlot?.day === + availability.day && selectedSlot?.start === availability.start + ? 'bg-blue-100' + : 'bg-gray-50 hover:bg-gray-100'}" + onclick={() => (selectedSlot = availability)} + > + <span> + {$t(`${availability.day.toLowerCase()}`)}: + {availability.start} - {availability.end} + </span> + </button> + {/each} + </ul> + {:else} + <p>{$t('register.noAvailability')}</p> + {/if} + + <div class="mt-4 flex gap-2 justify-end"> + <button class="btn btn-ghost" onclick={() => (showSchedulePopup = false)}> + {$t('button.cancel')} + </button> + <button class="btn btn-primary" onclick={confirmMeeting} disabled={!selectedSlot}> + {$t('register.confirm')} + </button> + </div> + </div> + </div> + {/if} </div> <style lang="postcss"> diff --git a/frontend/src/routes/register/[[studyId]]/+page.ts b/frontend/src/routes/register/[[studyId]]/+page.ts index 60dfddf0..9f2b5121 100644 --- a/frontend/src/routes/register/[[studyId]]/+page.ts +++ b/frontend/src/routes/register/[[studyId]]/+page.ts @@ -1,4 +1,5 @@ import { getStudiesAPI, getStudyAPI } from '$lib/api/studies'; +import { getUsersAPI } from '$lib/api/users'; import Study from '$lib/types/study'; import type { Load } from '@sveltejs/kit'; @@ -6,22 +7,22 @@ export const load: Load = async ({ parent, fetch, params }) => { const { user } = await parent(); const sStudyId: string | undefined = params.studyId; + let study = null; if (sStudyId) { const studyId = parseInt(sStudyId); if (studyId) { - const study = Study.parse(await getStudyAPI(fetch, studyId)); - if (study) { - return { - study - }; - } + study = Study.parse(await getStudyAPI(fetch, studyId)); } } const studies = Study.parseAll(await getStudiesAPI(fetch)); + const users = await getUsersAPI(fetch); + const tutors = users.filter((user) => user.type === 1); return { - studyError: true, - studies + studyError: !study, + study, + studies, + tutors }; }; diff --git a/frontend/src/routes/tutor/+layout.server.ts b/frontend/src/routes/tutor/+layout.server.ts deleted file mode 100644 index ebb1660b..00000000 --- a/frontend/src/routes/tutor/+layout.server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { type ServerLoad, error, redirect } from '@sveltejs/kit'; - -export const load: ServerLoad = async ({ locals, url }) => { - if (locals.user == null || locals.user == undefined) { - if (url.pathname.startsWith('/tutor/register')) { - return {}; - } - redirect(303, '/login'); - } - - // const user = JSON.parse(locals.user); - const user = typeof locals.user === 'string' ? JSON.parse(locals.user) : locals.user; - if (user == null || user == undefined || user.type > 1) { - error(403, 'Forbidden'); - } -}; diff --git a/frontend/src/routes/tutor/register/+page.server.ts b/frontend/src/routes/tutor/register/+page.server.ts deleted file mode 100644 index 7cc71b9d..00000000 --- a/frontend/src/routes/tutor/register/+page.server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { redirect, type Actions } from '@sveltejs/kit'; - -export const actions: Actions = { - register: async ({ request, fetch }) => { - const formData = await request.formData(); - - const email = formData.get('email'); - const nickname = formData.get('nickname'); - const password = formData.get('password'); - const is_tutor = formData.get('is_tutor') === 'true'; - - if (!email || !nickname || !password) { - return { message: 'Missing required fields' }; - } - - const registerResponse = await fetch('/api/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, nickname, password, is_tutor }) - }); - - const registerResult = await registerResponse.json(); - - if (!registerResponse.ok) { - return { message: registerResult.detail || 'Unknown error occurred' }; - } - - const loginResponse = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - credentials: 'include' - }); - - if (!loginResponse.ok) { - return { message: 'Failed to log in after registration.' }; - } - - const user_id = registerResult; - const userResponse = await fetch(`/api/users/${user_id}`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include' - }); - - if (!userResponse.ok) { - return { message: 'Failed to retrieve user data.' }; - } - - return redirect(303, '/tutor/register'); - } -}; diff --git a/frontend/src/routes/tutor/register/+page.svelte b/frontend/src/routes/tutor/register/+page.svelte deleted file mode 100644 index 86784507..00000000 --- a/frontend/src/routes/tutor/register/+page.svelte +++ /dev/null @@ -1,454 +0,0 @@ -<script lang="ts"> - import config from '$lib/config'; - import { locale, t } from '$lib/services/i18n'; - import { toastAlert, toastWarning } from '$lib/utils/toasts'; - import Timeslots from '$lib/components/users/timeslots.svelte'; - import { patchUserAPI } from '$lib/api/users'; - import { Icon, Envelope, Key, UserCircle, Calendar, QuestionMarkCircle } from 'svelte-hero-icons'; - import Typingtest from '$lib/components/tests/typingtest.svelte'; - import { formatToUTCDate } from '$lib/utils/date'; - import type { PageData } from './$types'; - import { page } from '$app/stores'; - - let { data }: { data: PageData } = $props(); - let user = data.user; - - let current_step = $state(user ? 3 : 1); - - let message = $state(''); - let form = $page.form; - - let ui_language: string = $locale; - let home_language: string; - let birthdate: string; - let gender: string; - let calcom_link = ''; - - let timeslots = 0n; - - async function onData() { - if (!user) { - toastAlert('Failed to get current user'); - return; - } - - if (!home_language || !birthdate || !gender) { - message = $t('register.error.emptyFields'); - return; - } - - const res = await patchUserAPI(fetch, user.id, { - ui_language, - home_language, - birthdate, - gender - }); - - if (!res) { - message = $t('register.error.metadata'); - return; - } - - current_step++; - } - - async function onAvailabilities() { - if (!user) { - toastAlert('Failed to get current user'); - return; - } - if (!calcom_link || !calcom_link.startsWith('https://cal.com/')) { - toastWarning($t('timeslots.calcomWarning')); - return; - } - const res = await patchUserAPI(fetch, user.id, { - calcom_link - }); - - if (!res) return; - - current_step++; - } - - async function onTyping() { - current_step++; - } -</script> - -{#if form?.message} - <div class="alert alert-error">{form.message}</div> -{/if} - -<div class="header mx-auto my-5"> - <ul class="steps text-xs"> - <li class="step" class:step-primary={current_step >= 1}> - {$t('register.tab.consent')} - </li> - <li class="step" class:step-primary={current_step >= 2}> - {$t('register.tab.signup')} - </li> - <li class="step" class:step-primary={current_step >= 3}> - {$t('register.tab.information')} - </li> - <li class="step" class:step-primary={current_step >= 4}> - {$t('register.tab.availabilities')} - </li> - <li class="step" class:step-primary={current_step >= 5} data-content="?"> - {$t('register.tab.continue')} - </li> - <li class="step" class:step-primary={current_step >= 6} data-content=""> - {$t('register.tab.test')} - </li> - <li class="step" class:step-primary={current_step >= 7} data-content="★"> - {$t('register.tab.start')} - </li> - </ul> -</div> - -<div class="max-w-screen-md mx-auto p-5"> - {#if message} - <div class="alert alert-error text-content text-base-100 py-2 mb-4"> - {message} - </div> - {/if} - {#if current_step == 1} - <div class="join join-vertical w-full"> - <div class="join-item"> - <h2 class="text-xl font-bold text-center">{$t('register.consentTutor.title')}</h2> - <p class="m-5">{@html $t('register.consentTutor.intro')}</p> - </div> - <div class="collapse collapse-arrow join-item border border-base-300"> - <input type="radio" name="consent-accordion" checked="checked" /> - <div class="collapse-title font-medium">{$t('register.consentTutor.participation')}</div> - <div class="collapse-content"> - <p>{@html $t('register.consentTutor.participationD')}</p> - </div> - </div> - <div class="collapse collapse-arrow join-item border border-base-300"> - <input type="radio" name="consent-accordion" /> - <div class="collapse-title font-medium">{$t('register.consentTutor.privacy')}</div> - <div class="collapse-content"><p>{@html $t('register.consentTutor.privacyD')}</p></div> - </div> - <div class="collapse collapse-arrow join-item border border-base-300"> - <input type="radio" name="consent-accordion" /> - <div class="collapse-title font-medium">{$t('register.consentTutor.rights')}</div> - <div class="collapse-content"> - <p> - {$t('register.consentTutor.rightsD')} - <a - class="link link-primary" - href="mailto:{$t('register.consentTutor.studyData.emailD')}" - >{$t('register.consentTutor.studyData.emailD')}</a - >. - </p> - </div> - </div> - <div class="collapse collapse-arrow join-item border border-base-300"> - <input type="radio" name="consent-accordion" /> - <div class="collapse-title font-medium">{$t('register.consentTutor.studyData.title')}</div> - <div class="collapse-content"> - <dl class="text-sm"> - <div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1"> - <dt class="font-medium">{$t('register.consentTutor.studyData.study')}</dt> - <dd class="text-gray-700 sm:col-span-2"> - {$t('register.consentTutor.studyData.studyD')} - </dd> - </div> - <div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1"> - <dt class="font-medium">{$t('register.consentTutor.studyData.project')}</dt> - <dd class="text-gray-700 sm:col-span-2"> - {$t('register.consentTutor.studyData.projectD')} - </dd> - </div> - <div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1"> - <dt class="font-medium">{$t('register.consentTutor.studyData.university')}</dt> - <dd class="text-gray-700 sm:col-span-2"> - {$t('register.consentTutor.studyData.universityD')} - </dd> - </div> - <div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1"> - <dt class="font-medium">{$t('register.consentTutor.studyData.address')}</dt> - <dd class="text-gray-700 sm:col-span-2"> - {$t('register.consentTutor.studyData.addressD')} - </dd> - </div> - <div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1"> - <dt class="font-medium">{$t('register.consentTutor.studyData.person')}</dt> - <dd class="text-gray-700 sm:col-span-2"> - {$t('register.consentTutor.studyData.personD')} - </dd> - </div> - <div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1"> - <dt class="font-medium">{$t('register.consentTutor.studyData.email')}</dt> - <dd class="text-gray-700 sm:col-span-2"> - <a href="mailto:{$t('register.consentTutor.studyData.emailD')}" class="link" - >{$t('register.consentTutor.studyData.emailD')}</a - > - </dd> - </div> - </dl> - </div> - </div> - </div> - <div class="form-control"> - <button class="button mt-4" onclick={() => current_step++}> - {$t('register.consentTutor.ok')} - </button> - </div> - {:else if current_step == 2} - <div class="space-y-4"> - <!-- Step 2: Tutor Registration Form --> - <form method="POST" action="?/register"> - <label for="email" class="form-control"> - <div class="label"> - <span class="label-text">{$t('register.email')}</span> - <span class="label-text-alt">{$t('register.email.note')}</span> - </div> - <div class="input flex items-center"> - <Icon src={Envelope} class="w-4 mr-2 opacity-70" solid /> - <input - type="email" - name="email" - class="grow" - placeholder={$t('register.email.ph')} - required - /> - </div> - </label> - <label for="nickname" class="form-control"> - <div class="label"> - <span class="label-text">{$t('register.nickname')}</span> - <span class="label-text-alt">{$t('register.nickname.note')}</span> - </div> - <div class="input flex items-center"> - <Icon src={UserCircle} class="w-4 mr-2 opacity-70" solid /> - <input - type="text" - name="nickname" - class="grow" - placeholder={$t('register.nickname.ph')} - required - /> - </div> - </label> - <label for="password" class="form-control"> - <div class="label"> - <span class="label-text">{$t('register.password')}</span> - <span class="label-text-alt">{$t('register.password.note')}</span> - </div> - <div class="input flex items-center"> - <Icon src={Key} class="w-4 mr-2 opacity-70" solid /> - <input - type="password" - name="password" - class="grow" - placeholder={$t('register.password')} - required - /> - </div> - </label> - <label for="confirmPassword" class="form-control"> - <div class="label"> - <span class="label-text">{$t('register.confirmPassword')}</span> - </div> - <div class="input flex items-center"> - <Icon src={Key} class="w-4 mr-2 opacity-70" solid /> - <input - type="password" - name="confirmPassword" - class="grow" - placeholder={$t('register.confirmPassword')} - required - /> - </div> - </label> - <input type="hidden" name="is_tutor" value="true" /> - - <div class="form-control"> - <button type="submit" class="button mt-4"> - {$t('register.signup')} - </button> - </div> - </form> - - {#if $page.form?.message} - <div class="alert alert-error text-content text-base-100 py-2 mt-4"> - {$page.form.message} - </div> - {/if} - </div> - {:else if current_step == 3} - <div class="space-y-2"> - <div class="p-5 text-sm text-prose"> - {@html $t('register.welcome')} - </div> - <div class="form-control"> - <label for="homeLanguage" class="label"> - <span class="label-text">{$t('register.homeLanguage')}</span> - <span class="label-text-alt">{$t('register.homeLanguage.note')}</span> - </label> - <select - class="select select-bordered" - id="homeLanguage" - name="homeLanguage" - required - bind:value={home_language} - > - <option disabled selected value="">{$t('register.homeLanguage')}</option> - {#each Object.entries(config.PRIMARY_LANGUAGE) as [code, name]} - <option value={code}>{name}</option> - {/each} - </select> - </div> - <div class="form-control"> - <label for="birthyear" class="label"> - <span class="label-text">{$t('register.birthyear')}</span> - </label> - <select - class="select select-bordered" - id="birthyear" - name="birthyear" - required - onchange={(e) => (birthdate = formatToUTCDate(new Date(e.target.value, 1, 1)))} - > - <option disabled selected value="">{$t('register.birthyear')}</option> - {#each Array.from({ length: 82 }, (_, i) => i + 1931).reverse() as year} - <option value={year}>{year}</option> - {/each} - </select> - </div> - <div class="form-control space-y-1"> - <label for="gender" class="label"> - <span class="label-text">{$t('register.gender')}</span> - <span class="label-text-alt">{$t('register.gender.note')}</span> - </label> - <div class="label justify-normal gap-2 py-0"> - <input - type="radio" - class="radio" - id="male" - name="gender" - value="male" - onchange={() => (gender = 'male')} - /> - <label for="male" class="label-text cursor-pointer"> - {$t('register.genders.male')} - </label> - </div> - <div class="label justify-normal gap-2 py-0"> - <input - type="radio" - class="radio" - id="female" - name="gender" - value="female" - onchange={() => (gender = 'female')} - /> - <label for="female" class="label-text cursor-pointer"> - {$t('register.genders.female')} - </label> - </div> - <div class="label justify-normal gap-2 py-0"> - <input - type="radio" - class="radio" - id="other" - name="gender" - value="other" - onchange={() => (gender = 'other')} - /> - <label for="other" class="label-text cursor-pointer"> - {$t('register.genders.other')} - </label> - </div> - <div class="label justify-normal gap-2 py-0"> - <input - type="radio" - class="radio" - id="na" - name="gender" - value="na" - onchange={() => (gender = 'na')} - /> - <label for="na" class="label-text cursor-pointer"> - {$t('register.genders.na')} - </label> - </div> - </div> - <div class="form-control"> - <button class="button mt-4" onclick={onData}>{$t('button.submit')}</button> - </div> - </div> - {:else if current_step == 4} - <h2 class="my-4 text-xl">{$t('timeslots.setAvailabilities')}</h2> - <Timeslots bind:timeslots /> - <div class="form-control mt-4"> - <label class="label" for="calcom"> - <span class="label-text"> - {$t('timeslots.calcom')} - <a - href="https://forge.uclouvain.be/sbibauw/languagelab/-/blob/93897d67f63ec81ebbe13b10035e4cd5a3a09071/docs/cal.com.md" - target="_blank" - > - <Icon - src={QuestionMarkCircle} - class="w-5 h-5 cursor-pointer inline" - title="Documentation" - solid - /> - </a> - </span> - </label> - <div class="input flex items-center"> - <Icon src={Calendar} class="w-5 h-5 mr-2 opacity-70" solid /> - <input - type="text" - id="calcom" - class="grow" - placeholder="https://cal.com/username/tutoring" - bind:value={calcom_link} - /> - </div> - </div> - <div class="form-control"> - <button class="button mt-4" onclick={onAvailabilities}>{$t('button.submit')}</button> - </div> - {:else if current_step == 5} - <div class="text-center"> - <p class="text-center"> - {@html $t('register.continue')} - </p> - <button class="button mt-4 w-full" onclick={() => (current_step = 6)}> - {$t('register.continueButton')} - </button> - <button class="button mt-4 w-full" onclick={() => (document.location.href = '/')}> - {$t('register.startFastButton')} - </button> - </div> - {:else if current_step == 6} - <Typingtest onFinish={onTyping} {user} /> - {:else if current_step == 7} - <div class="text-center"> - <p class="text-center"> - {@html $t('register.start')} - </p> - <button class="button mt-4 m-auto" onclick={() => (document.location.href = '/')}> - {$t('register.startButton')} - </button> - </div> - {/if} -</div> - -<style lang="postcss"> - /* input:not([type='radio']) { - @apply w-full; - } */ - - .label-text-alt { - @apply opacity-50 ml-8; - } - - .steps { - @apply text-base-300; - } -</style> -- GitLab