feat: introduce register API
This commit is contained in:
parent
83579f5c60
commit
77ccdc6862
9 changed files with 145 additions and 51 deletions
|
|
@ -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)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
24
app/routes/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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é" },
|
||||||
|
|
@ -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
6
public/classes.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"value": "MPSI 2",
|
||||||
|
"label": "MPSI 2 - Taupe"
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Add table
Reference in a new issue