ui: add register page
Some checks failed
Deploy to Netlify / Deploy to Netlify (push) Failing after 1m10s

This commit is contained in:
Nathan Lamy 2025-07-28 23:29:27 +02:00
parent ccaaf45e29
commit 938ed8a4df
14 changed files with 10954 additions and 48 deletions

View file

@ -0,0 +1,81 @@
import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "~/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
export function Combobox({
values = [],
defaultText = "Select value...",
placeholderText = "Search...",
emptyText = "No value found.",
renderValue = (value) => value
? values.find((current) => current.value === value)?.label!
: defaultText,
}: {
values?: { value: string; label: string }[]
emptyText?: string
placeholderText?: string
defaultText?: string
renderValue?: (value: string) => string
}) {
const [open, setOpen] = React.useState(false)
const [current, setValue] = React.useState("")
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{renderValue(current)}
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={placeholderText} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{values.map(({ value, label }) => (
<CommandItem
key={value}
value={value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue)
setOpen(false)
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
current === value ? "opacity-100" : "opacity-0"
)}
/>
{label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -3,6 +3,7 @@ import { useState } from "react";
import { Alert, AlertDescription } from "./ui/alert";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import { verifyOtp } from "~/lib/api";
import { useNavigate } from "react-router";
export default function OtpInput({
email,
@ -13,6 +14,7 @@ export default function OtpInput({
setOtpError: (error: string | null) => void;
otpError: string | null;
}) {
const navigate = useNavigate();
const [isVerifying, setIsVerifying] = useState(false);
// Handle OTP verification
@ -22,7 +24,10 @@ export default function OtpInput({
verifyOtp({ otpCode, email })
.then(() => {
setIsVerifying(false);
// TODO: Proper redirect to success page
// TODO: Check if new user ??
navigate("/success?email=" + encodeURIComponent(email), {
replace: true,
});
})
.catch((error) =>
setOtpError(

View file

@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "~/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View file

@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "~/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View file

@ -14,8 +14,12 @@ const SUPPORT_MAIL = "mailto:" + import.meta.env.VITE_SUPPORT_EMAIL;
export default function AuthLayout({
children,
title = "Connexion",
description = "Entrez votre email pour recevoir un lien de connexion ou un code de vérification.",
}: {
children: React.ReactNode;
title?: string;
description?: string;
}) {
return (
<main className="flex h-full flex-col items-center justify-center p-4">
@ -24,11 +28,10 @@ export default function AuthLayout({
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center justify-center">
<UserLock className="h-6 w-6 mr-2" />
Connexion
{title}
</CardTitle>
<CardDescription className="text-center">
Entrez votre email pour recevoir un lien de connexion ou un code
de vérification.
{description}
</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>

View file

@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}

View file

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

View file

@ -40,9 +40,11 @@ export default function Login() {
await requestLogin(email, token)
.then((data) => {
setIsLoading(false);
const url = `/verify?email=${encodeURIComponent(email)}&token=${
data?.token
}`;
const url = `/verify?email=${encodeURIComponent(email)}`
// TODO: Magic link (wss connection token)
// &token=${
// data?.token
// }`;
navigate(url, {
replace: true,
});

149
app/routes/register.tsx Normal file
View file

@ -0,0 +1,149 @@
import type { Route } from "./+types/login";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { AlertCircleIcon, IdCardIcon, LoaderCircle, LogIn, MailIcon } from "lucide-react";
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { useState } from "react";
import AuthLayout from "~/layout";
import { useNavigate, useSearchParams } from "react-router";
import { capitalizeFirstLetter } from "~/lib/utils";
import { Combobox } from "~/components/combobox";
export function meta({ }: Route.MetaArgs) {
return [
{ title: "Khollisé - Inscription" },
{ name: "description", content: "Connectez-vous à Khollisé" },
];
}
export default function Register() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const email = searchParams.get("email")!;
const [emailFirst, emailLast] = getNameFromEmail(email);
const [firstName, setFirstName] = useState(emailFirst);
const [lastName, setLastName] = useState(emailLast);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// TODO: Validate...
// TODO: Check if combobox is valid (not empty !!)
setIsLoading(true);
// TODO: Fetch function
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await sleep(5000)
.then((data) => {
setIsLoading(false);
// TODO: Callback
})
.catch((err) => {
setIsLoading(false);
setError(err.message);
});
};
return (
<AuthLayout title="Inscription" description="Créez votre compte pour accéder à Khollisé.">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-500">
<MailIcon className="h-5 w-5" />
</div>
<Input
id="email"
type="email"
className="disabled:opacity-100 pl-10"
value={email}
disabled
required
/>
</div>
</div>
<div className="space-y-2 flex gap-4">
<div>
<Label htmlFor="password">Prénom</Label>
<Input
id="firstName"
type="text"
placeholder="Claire"
value={firstName}
onChange={(e) => setFirstName(capitalizeFirstLetter(e.target.value))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Nom</Label>
<Input
id="lastName"
type="text"
placeholder="DUPONT"
value={lastName}
onChange={(e) => setLastName(e.target.value.toUpperCase())}
required
/>
</div>
</div>
<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..."
placeholderText="Rechercher"
/>
</div>
</div>
{error && (
<Alert variant="destructive" className="pb-2">
<AlertCircleIcon className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin mr-2" />
Inscription en cours...
</>
) : (
<>
<LogIn className="h-4 w-4 mr-2" />
S'inscrire
</>
)}
</Button>
</form>
</AuthLayout>
);
}
function getNameFromEmail(email: string): [string, string] {
const parts = email.split("@")[0].split(".");
if (parts.length < 2) return ["", ""];
const firstName = capitalizeFirstLetter(parts[0]);
const lastName = parts.slice(1).join(" ").toUpperCase();
return [firstName, lastName];
}

View file

@ -2,8 +2,8 @@ import type { Route } from "./+types/verify";
import {
ArrowLeft,
CheckCircleIcon,
LoaderIcon,
RefreshCwIcon,
// LoaderIcon,
// RefreshCwIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
@ -29,8 +29,8 @@ export default function Verify() {
return
}
const [isResending, setIsResending] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
// const [isResending, setIsResending] = useState(false);
// const [resendSuccess, setResendSuccess] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [otpError, setOtpError] = useState<string | null>(null);
@ -45,43 +45,43 @@ export default function Verify() {
return () => clearInterval(timer);
}, [cooldown]);
const handleResendOtp = async () => {
if (cooldown > 0) return;
// const handleResendOtp = async () => {
// if (cooldown > 0) return;
setIsResending(true);
setResendSuccess(false);
// setIsResending(true);
// setResendSuccess(false);
try {
// Appeler l'API pour renvoyer un code OTP
const response = await fetch("/api/auth/resend-otp", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
// try {
// // Appeler l'API pour renvoyer un code OTP
// const response = await fetch("/api/auth/resend-otp", {
// method: "POST",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify({ email }),
// });
const data = await response.json();
// const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Échec de l'envoi du nouveau code");
}
// if (!response.ok) {
// throw new Error(data.error || "Échec de l'envoi du nouveau code");
// }
// Afficher le message de succès
setResendSuccess(true);
// Démarrer le cooldown (60 secondes)
setCooldown(60);
} catch (error) {
console.error("Erreur lors de la demande d'un nouveau code:", error);
setOtpError(
error instanceof Error
? error.message
: "Échec de l'envoi du nouveau code"
);
} finally {
setIsResending(false);
}
};
// // Afficher le message de succès
// setResendSuccess(true);
// // Démarrer le cooldown (60 secondes)
// setCooldown(60);
// } catch (error) {
// console.error("Erreur lors de la demande d'un nouveau code:", error);
// setOtpError(
// error instanceof Error
// ? error.message
// : "Échec de l'envoi du nouveau code"
// );
// } finally {
// setIsResending(false);
// }
// };
return (
<AuthLayout>
@ -98,8 +98,10 @@ export default function Verify() {
<ul className="list-disc pl-5 space-y-1 text-sm">
<li>Consultez votre email (vérifiez le dossier spam)</li>
<li>
{/* TODO: Magic link
Cliquez sur le lien pour vous connecter ou entrez le code à 6
chiffres
chiffres */}
Entrez le code à 6 chiffres que vous avez reçu
</li>
</ul>
</div>
@ -114,6 +116,7 @@ export default function Verify() {
otpError={otpError}
/>
{/* TODO: Resend OTP
{resendSuccess && (
<Alert className="bg-green-50 border-green-200 mt-2">
<CheckCircleIcon className="h-4 w-4 text-green-600" />
@ -121,7 +124,7 @@ export default function Verify() {
Un nouveau code a é envoyé à votre adresse email
</AlertDescription>
</Alert>
)}
)} */}
<div className="flex justify-between mt-4">
<Button
@ -134,7 +137,7 @@ export default function Verify() {
Retour
</Button>
<Button
{/* <Button
type="button"
variant="secondary"
size="sm"
@ -158,7 +161,7 @@ export default function Verify() {
Renvoyer le code
</>
)}
</Button>
</Button> */}
</div>
</div>
</div>

View file

@ -11,11 +11,15 @@
},
"dependencies": {
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-slot": "^1.2.3",
"@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"input-otp": "^1.4.2",
"isbot": "^5.1.27",
"lucide-react": "^0.511.0",

10277
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

7
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,7 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
- esbuild
- netlify-cli
- sharp
- unix-dgram