ui: add register page
Some checks failed
Deploy to Netlify / Deploy to Netlify (push) Failing after 1m10s
Some checks failed
Deploy to Netlify / Deploy to Netlify (push) Failing after 1m10s
This commit is contained in:
parent
ccaaf45e29
commit
938ed8a4df
14 changed files with 10954 additions and 48 deletions
81
app/components/combobox.tsx
Normal file
81
app/components/combobox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
182
app/components/ui/command.tsx
Normal file
182
app/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
141
app/components/ui/dialog.tsx
Normal file
141
app/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
48
app/components/ui/popover.tsx
Normal file
48
app/components/ui/popover.tsx
Normal 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 }
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
149
app/routes/register.tsx
Normal 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];
|
||||
}
|
||||
|
|
@ -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 été 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>
|
||||
|
|
|
|||
|
|
@ -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
10277
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
onlyBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- netlify-cli
|
||||
- sharp
|
||||
- unix-dgram
|
||||
Loading…
Add table
Reference in a new issue