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...",
placeholderText = "Search...",
emptyText = "No value found.",
renderValue = (value) => value
? values.find((current) => current.value === value)?.label!
: defaultText,
current, setValue
}: {
values?: { value: string; label: string }[]
emptyText?: string
placeholderText?: string
defaultText?: string
renderValue?: (value: string) => string
current?: string
setValue: (value: string) => void
}) {
const [open, setOpen] = React.useState(false)
const [current, setValue] = React.useState("")
return (
<Popover open={open} onOpenChange={setOpen}>
@ -44,7 +42,9 @@ export function Combobox({
aria-expanded={open}
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" />
</Button>
</PopoverTrigger>
@ -59,7 +59,7 @@ export function Combobox({
key={value}
value={value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue)
setValue(currentValue === current ? "" : currentValue)
setOpen(false)
}}
>

View file

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

View file

@ -1,6 +1,6 @@
const BASE_URL = import.meta.env.VITE_API_URL;
const makeRequest = async (
const makePostRequest = async (
url: string,
body: object,
error = "Une erreur est survenue",
@ -10,8 +10,10 @@ const makeRequest = async (
method,
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify(body),
credentials: "include", // Include cookies for authentication
});
const data = await response.json();
@ -19,8 +21,24 @@ const makeRequest = async (
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) => {
return makeRequest(
return makePostRequest(
"/auth/request",
{ email, token },
"Échec de la demande de connexion"
@ -34,17 +52,39 @@ export const verifyOtp = async ({
otpCode: string;
email: string;
}) => {
return makeRequest(
return makePostRequest(
"/auth/verify",
{ email, code: otpCode },
"Code de vérification invalide"
);
};
export const sendOtp = async (email: string) => {
return makeRequest(
"/auth/send-otp",
{ email },
"Échec de l'envoi du code de vérification"
export const getClasses = async () => {
try {
const res = await fetch("/classes.json");
return res.json();
} 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";
export default [
index("routes/login.tsx"),
index("routes/index.tsx"),
route("/login", "routes/login.tsx"),
route("/verify", "routes/verify.tsx"),
route("/register", "routes/register.tsx"),
] 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 { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { useState } from "react";
import { useEffect, useState } from "react";
import AuthLayout from "~/layout";
import { useNavigate, useSearchParams } from "react-router";
import { capitalizeFirstLetter } from "~/lib/utils";
import { Combobox } from "~/components/combobox";
import { getClasses, registerUser } from "~/lib/api";
export function meta({ }: Route.MetaArgs) {
return [
@ -25,6 +26,22 @@ export default function Register() {
const [emailFirst, emailLast] = getNameFromEmail(email);
const [firstName, setFirstName] = useState(emailFirst);
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 [isLoading, setIsLoading] = useState(false);
@ -32,16 +49,18 @@ export default function Register() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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);
// TODO: Fetch function
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await sleep(5000)
.then((data) => {
await registerUser(firstName, lastName, className, token!)
.then(() => {
setIsLoading(false);
// TODO: Callback
navigate("/", {
replace: true,
})
})
.catch((err) => {
setIsLoading(false);
@ -98,19 +117,13 @@ export default function Register() {
<div className="space-y-2">
<Label htmlFor="password">Classe</Label>
<div className="block">
{/* TODO: Pass ref to combobox to validate */}
<Combobox
defaultText="Sélectionnez votre classe..."
// TODO: Fetch function
values={[
{ 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..."
values={classes}
emptyText="Aucune classe trouvée"
placeholderText="Rechercher"
current={className}
setValue={setClassName}
/>
</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 {
CheckCircleIcon,
@ -15,7 +15,7 @@ import {
} from "~/components/ui/card";
import { Button } from "~/components/ui/button";
export function meta({}: Route.MetaArgs) {
export function meta() {
return [
{ title: "Khollisé - Connexion" },
{ 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 AuthLayout from "~/layout";
export function meta({}: Route.MetaArgs) {
export function meta({ }: Route.MetaArgs) {
return [
{ title: "Khollisé - Connexion" },
{ name: "description", content: "Connectez-vous à Khollisé" },
@ -24,10 +24,13 @@ export default function Verify() {
const navigate = useNavigate();
const [searchParams] = useSearchParams()
const email = searchParams.get("email");
if (!email) {
// TODO: Redirect to login page
return
}
useEffect(() => {
if (!email) {
navigate("/login", { replace: true });
return;
}
}, [email]);
// const [isResending, setIsResending] = useState(false);
// const [resendSuccess, setResendSuccess] = useState(false);
@ -111,12 +114,12 @@ export default function Verify() {
Entrez le code de vérification
</h3>
<OtpInput
email={email}
email={email!}
setOtpError={setOtpError}
otpError={otpError}
/>
{/* TODO: Resend OTP
{/* TODO: Resend OTP
{resendSuccess && (
<Alert className="bg-green-50 border-green-200 mt-2">
<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"
}
]