feat: introduce register API

This commit is contained in:
Nathan Lamy 2025-07-29 11:05:49 +02:00
parent 83579f5c60
commit 77ccdc6862
9 changed files with 145 additions and 51 deletions

View file

@ -22,18 +22,16 @@ export function Combobox({
defaultText = "Select value...", defaultText = "Select value...",
placeholderText = "Search...", placeholderText = "Search...",
emptyText = "No value found.", emptyText = "No value found.",
renderValue = (value) => value current, setValue
? values.find((current) => current.value === value)?.label!
: defaultText,
}: { }: {
values?: { value: string; label: string }[] values?: { value: string; label: string }[]
emptyText?: string emptyText?: string
placeholderText?: string placeholderText?: string
defaultText?: string defaultText?: string
renderValue?: (value: string) => string current?: string
setValue: (value: string) => void
}) { }) {
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
const [current, setValue] = React.useState("")
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@ -44,7 +42,9 @@ export function Combobox({
aria-expanded={open} aria-expanded={open}
className="w-full justify-between" className="w-full justify-between"
> >
{renderValue(current)} {current
? values.find((value) => value.value === current)?.label!
: defaultText}
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@ -59,7 +59,7 @@ export function Combobox({
key={value} key={value}
value={value} value={value}
onSelect={(currentValue) => { onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue) setValue(currentValue === current ? "" : currentValue)
setOpen(false) setOpen(false)
}} }}
> >

View file

@ -22,20 +22,27 @@ export default function OtpInput({
setIsVerifying(true); setIsVerifying(true);
setOtpError(null); setOtpError(null);
verifyOtp({ otpCode, email }) verifyOtp({ otpCode, email })
.then(() => { .then((data) => {
setIsVerifying(false); setIsVerifying(false);
// TODO: Check if new user ?? // Check if user is registered
navigate("/success?email=" + encodeURIComponent(email), { if (data.token) {
replace: true, navigate(`/register?email=${data.email}&token=${data.token}`, {
}); replace: true,
});
} else {
navigate("/", {
replace: true,
})
}
}) })
.catch((error) => .catch((error) => {
setOtpError( setOtpError(
error instanceof Error error instanceof Error
? error.message ? error.message
: "Code de vérification invalide" : "Code de vérification invalide"
) )
); setIsVerifying(false)
});
}; };
return ( return (

View file

@ -1,6 +1,6 @@
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
const makeRequest = async ( const makePostRequest = async (
url: string, url: string,
body: object, body: object,
error = "Une erreur est survenue", error = "Une erreur est survenue",
@ -10,8 +10,10 @@ const makeRequest = async (
method, method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json",
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
credentials: "include", // Include cookies for authentication
}); });
const data = await response.json(); const data = await response.json();
@ -19,8 +21,24 @@ const makeRequest = async (
return data?.data || data; return data?.data || data;
}; };
// TODO: Use swr or react-query for caching and revalidation
// TODO: Cache all to localStorage or IndexedDB for offline support
const makeRequest = async (
url: string,
error = "Une erreur est survenue",
) => {
const response = await fetch(BASE_URL + url, { credentials: "include"});
const data = await response.json();
if (!response.ok) throw new Error(data.error || error);
return data?.data || data;
};
/**
* === AUTH API ===
*/
export const requestLogin = async (email: string, token: string) => { export const requestLogin = async (email: string, token: string) => {
return makeRequest( return makePostRequest(
"/auth/request", "/auth/request",
{ email, token }, { email, token },
"Échec de la demande de connexion" "Échec de la demande de connexion"
@ -34,17 +52,39 @@ export const verifyOtp = async ({
otpCode: string; otpCode: string;
email: string; email: string;
}) => { }) => {
return makeRequest( return makePostRequest(
"/auth/verify", "/auth/verify",
{ email, code: otpCode }, { email, code: otpCode },
"Code de vérification invalide" "Code de vérification invalide"
); );
}; };
export const sendOtp = async (email: string) => { export const getClasses = async () => {
return makeRequest( try {
"/auth/send-otp", const res = await fetch("/classes.json");
{ email }, return res.json();
"Échec de l'envoi du code de vérification" } catch (error) {
console.error("Error fetching classes:", error);
return [];
}
};
export const registerUser = async (
firstName: string,
lastName: string,
className: string,
token: string
) => {
return makePostRequest(
"/auth/register",
{ firstName, lastName, className, token },
"Échec de l'inscription"
); );
}; };
/**
* === COLLES API ===
*/
export const getColles = async (weekNumber: number, year: number) => {
return makeRequest(`/colles?week=${weekNumber}&year=${year}`, "Échec de la récupération des colles");
};

View file

@ -1,7 +1,8 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes"; import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [ export default [
index("routes/login.tsx"), index("routes/index.tsx"),
route("/login", "routes/login.tsx"),
route("/verify", "routes/verify.tsx"), route("/verify", "routes/verify.tsx"),
route("/register", "routes/register.tsx"), route("/register", "routes/register.tsx"),
] satisfies RouteConfig; ] satisfies RouteConfig;

24
app/routes/index.tsx Normal file
View file

@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { getColles } from "~/lib/api";
export default function Index() {
const [colles, setColles] = useState([]);
useEffect(() => {
// Fetch colles data from the API
getColles(12, 2025).then((data) => {
setColles(data);
}).catch((error) => {
console.error("Error fetching colles:", error);
});
}, []);
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center space-y-4">
<h1 className="text-2xl font-bold">Welcome to Khollisé!</h1>
<p>Got {colles.length} colles this week.</p>
</div>
</div>
);
}

View file

@ -4,11 +4,12 @@ import { AlertCircleIcon, IdCardIcon, LoaderCircle, LogIn, MailIcon } from "luci
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { useState } from "react"; import { useEffect, useState } from "react";
import AuthLayout from "~/layout"; import AuthLayout from "~/layout";
import { useNavigate, useSearchParams } from "react-router"; import { useNavigate, useSearchParams } from "react-router";
import { capitalizeFirstLetter } from "~/lib/utils"; import { capitalizeFirstLetter } from "~/lib/utils";
import { Combobox } from "~/components/combobox"; import { Combobox } from "~/components/combobox";
import { getClasses, registerUser } from "~/lib/api";
export function meta({ }: Route.MetaArgs) { export function meta({ }: Route.MetaArgs) {
return [ return [
@ -25,6 +26,22 @@ export default function Register() {
const [emailFirst, emailLast] = getNameFromEmail(email); const [emailFirst, emailLast] = getNameFromEmail(email);
const [firstName, setFirstName] = useState(emailFirst); const [firstName, setFirstName] = useState(emailFirst);
const [lastName, setLastName] = useState(emailLast); const [lastName, setLastName] = useState(emailLast);
const [className, setClassName] = useState("");
const token = searchParams.get("token");
useEffect(() => {
if (!email || !token) {
navigate("/login", { replace: true });
return;
}
}, [email, token]);
const [classes, setClasses] = useState<{ value: string; label: string }[]>([]);
useEffect(() => {
getClasses().then((data) => {
setClasses(data);
})
}, []);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -32,16 +49,18 @@ export default function Register() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
// TODO: Validate...
// TODO: Check if combobox is valid (not empty !!) // Validate inputs
if (!firstName || !lastName) return setError("Veuillez compléter votre prénom et nom.");
if (!className) return setError("Veuillez sélectionner votre classe.");
setIsLoading(true); setIsLoading(true);
// TODO: Fetch function
const sleep = (ms: number) => await registerUser(firstName, lastName, className, token!)
new Promise((resolve) => setTimeout(resolve, ms)); .then(() => {
await sleep(5000)
.then((data) => {
setIsLoading(false); setIsLoading(false);
// TODO: Callback navigate("/", {
replace: true,
})
}) })
.catch((err) => { .catch((err) => {
setIsLoading(false); setIsLoading(false);
@ -98,19 +117,13 @@ export default function Register() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Classe</Label> <Label htmlFor="password">Classe</Label>
<div className="block"> <div className="block">
{/* TODO: Pass ref to combobox to validate */}
<Combobox <Combobox
defaultText="Sélectionnez votre classe..." defaultText="Sélectionnez votre classe..."
// TODO: Fetch function values={classes}
values={[ emptyText="Aucune classe trouvée"
{ value: "1", label: "1ère" },
{ value: "2", label: "2ème" },
{ value: "3", label: "3ème" },
{ value: "4", label: "4ème" },
{ value: "5", label: "5ème" },
]}
emptyText="Chargement..."
placeholderText="Rechercher" placeholderText="Rechercher"
current={className}
setValue={setClassName}
/> />
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import type { Route } from "./+types/success"; // import type { Route } from "./+types/success";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import {
CheckCircleIcon, CheckCircleIcon,
@ -15,7 +15,7 @@ import {
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
export function meta({}: Route.MetaArgs) { export function meta() {
return [ return [
{ title: "Khollisé - Connexion" }, { title: "Khollisé - Connexion" },
{ name: "description", content: "Connexion réussie à Khollisé" }, { name: "description", content: "Connexion réussie à Khollisé" },

View file

@ -13,7 +13,7 @@ import { Alert, AlertDescription } from "~/components/ui/alert";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import AuthLayout from "~/layout"; import AuthLayout from "~/layout";
export function meta({}: Route.MetaArgs) { export function meta({ }: Route.MetaArgs) {
return [ return [
{ title: "Khollisé - Connexion" }, { title: "Khollisé - Connexion" },
{ name: "description", content: "Connectez-vous à Khollisé" }, { name: "description", content: "Connectez-vous à Khollisé" },
@ -24,10 +24,13 @@ export default function Verify() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const email = searchParams.get("email"); const email = searchParams.get("email");
if (!email) {
// TODO: Redirect to login page useEffect(() => {
return if (!email) {
} navigate("/login", { replace: true });
return;
}
}, [email]);
// const [isResending, setIsResending] = useState(false); // const [isResending, setIsResending] = useState(false);
// const [resendSuccess, setResendSuccess] = useState(false); // const [resendSuccess, setResendSuccess] = useState(false);
@ -111,12 +114,12 @@ export default function Verify() {
Entrez le code de vérification Entrez le code de vérification
</h3> </h3>
<OtpInput <OtpInput
email={email} email={email!}
setOtpError={setOtpError} setOtpError={setOtpError}
otpError={otpError} otpError={otpError}
/> />
{/* TODO: Resend OTP {/* TODO: Resend OTP
{resendSuccess && ( {resendSuccess && (
<Alert className="bg-green-50 border-green-200 mt-2"> <Alert className="bg-green-50 border-green-200 mt-2">
<CheckCircleIcon className="h-4 w-4 text-green-600" /> <CheckCircleIcon className="h-4 w-4 text-green-600" />

6
public/classes.json Normal file
View file

@ -0,0 +1,6 @@
[
{
"value": "MPSI 2",
"label": "MPSI 2 - Taupe"
}
]