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 { Alert, AlertDescription } from "./ui/alert";
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||||
import { verifyOtp } from "~/lib/api";
|
import { verifyOtp } from "~/lib/api";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
export default function OtpInput({
|
export default function OtpInput({
|
||||||
email,
|
email,
|
||||||
|
|
@ -13,6 +14,7 @@ export default function OtpInput({
|
||||||
setOtpError: (error: string | null) => void;
|
setOtpError: (error: string | null) => void;
|
||||||
otpError: string | null;
|
otpError: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
|
||||||
// Handle OTP verification
|
// Handle OTP verification
|
||||||
|
|
@ -22,7 +24,10 @@ export default function OtpInput({
|
||||||
verifyOtp({ otpCode, email })
|
verifyOtp({ otpCode, email })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIsVerifying(false);
|
setIsVerifying(false);
|
||||||
// TODO: Proper redirect to success page
|
// TODO: Check if new user ??
|
||||||
|
navigate("/success?email=" + encodeURIComponent(email), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
setOtpError(
|
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({
|
export default function AuthLayout({
|
||||||
children,
|
children,
|
||||||
|
title = "Connexion",
|
||||||
|
description = "Entrez votre email pour recevoir un lien de connexion ou un code de vérification.",
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<main className="flex h-full flex-col items-center justify-center p-4">
|
<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">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className="text-2xl font-bold flex items-center justify-center">
|
<CardTitle className="text-2xl font-bold flex items-center justify-center">
|
||||||
<UserLock className="h-6 w-6 mr-2" />
|
<UserLock className="h-6 w-6 mr-2" />
|
||||||
Connexion
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-center">
|
<CardDescription className="text-center">
|
||||||
Entrez votre email pour recevoir un lien de connexion ou un code
|
{description}
|
||||||
de vérification.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>{children}</CardContent>
|
<CardContent>{children}</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge";
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
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 [
|
export default [
|
||||||
index("routes/login.tsx"),
|
index("routes/login.tsx"),
|
||||||
route("/verify", "routes/verify.tsx"),
|
route("/verify", "routes/verify.tsx"),
|
||||||
route("/success", "routes/success.tsx"),
|
route("/register", "routes/register.tsx"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,11 @@ export default function Login() {
|
||||||
await requestLogin(email, token)
|
await requestLogin(email, token)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
const url = `/verify?email=${encodeURIComponent(email)}&token=${
|
const url = `/verify?email=${encodeURIComponent(email)}`
|
||||||
data?.token
|
// TODO: Magic link (wss connection token)
|
||||||
}`;
|
// &token=${
|
||||||
|
// data?.token
|
||||||
|
// }`;
|
||||||
navigate(url, {
|
navigate(url, {
|
||||||
replace: true,
|
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 {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
LoaderIcon,
|
// LoaderIcon,
|
||||||
RefreshCwIcon,
|
// RefreshCwIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
@ -29,8 +29,8 @@ export default function Verify() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const [isResending, setIsResending] = useState(false);
|
// const [isResending, setIsResending] = useState(false);
|
||||||
const [resendSuccess, setResendSuccess] = useState(false);
|
// const [resendSuccess, setResendSuccess] = useState(false);
|
||||||
const [cooldown, setCooldown] = useState(0);
|
const [cooldown, setCooldown] = useState(0);
|
||||||
const [otpError, setOtpError] = useState<string | null>(null);
|
const [otpError, setOtpError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -45,43 +45,43 @@ export default function Verify() {
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [cooldown]);
|
}, [cooldown]);
|
||||||
|
|
||||||
const handleResendOtp = async () => {
|
// const handleResendOtp = async () => {
|
||||||
if (cooldown > 0) return;
|
// if (cooldown > 0) return;
|
||||||
|
|
||||||
setIsResending(true);
|
// setIsResending(true);
|
||||||
setResendSuccess(false);
|
// setResendSuccess(false);
|
||||||
|
|
||||||
try {
|
// try {
|
||||||
// Appeler l'API pour renvoyer un code OTP
|
// // Appeler l'API pour renvoyer un code OTP
|
||||||
const response = await fetch("/api/auth/resend-otp", {
|
// const response = await fetch("/api/auth/resend-otp", {
|
||||||
method: "POST",
|
// method: "POST",
|
||||||
headers: {
|
// headers: {
|
||||||
"Content-Type": "application/json",
|
// "Content-Type": "application/json",
|
||||||
},
|
// },
|
||||||
body: JSON.stringify({ email }),
|
// body: JSON.stringify({ email }),
|
||||||
});
|
// });
|
||||||
|
|
||||||
const data = await response.json();
|
// const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
// if (!response.ok) {
|
||||||
throw new Error(data.error || "Échec de l'envoi du nouveau code");
|
// throw new Error(data.error || "Échec de l'envoi du nouveau code");
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Afficher le message de succès
|
// // Afficher le message de succès
|
||||||
setResendSuccess(true);
|
// setResendSuccess(true);
|
||||||
// Démarrer le cooldown (60 secondes)
|
// // Démarrer le cooldown (60 secondes)
|
||||||
setCooldown(60);
|
// setCooldown(60);
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
console.error("Erreur lors de la demande d'un nouveau code:", error);
|
// console.error("Erreur lors de la demande d'un nouveau code:", error);
|
||||||
setOtpError(
|
// setOtpError(
|
||||||
error instanceof Error
|
// error instanceof Error
|
||||||
? error.message
|
// ? error.message
|
||||||
: "Échec de l'envoi du nouveau code"
|
// : "Échec de l'envoi du nouveau code"
|
||||||
);
|
// );
|
||||||
} finally {
|
// } finally {
|
||||||
setIsResending(false);
|
// setIsResending(false);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
|
|
@ -98,8 +98,10 @@ export default function Verify() {
|
||||||
<ul className="list-disc pl-5 space-y-1 text-sm">
|
<ul className="list-disc pl-5 space-y-1 text-sm">
|
||||||
<li>Consultez votre email (vérifiez le dossier spam)</li>
|
<li>Consultez votre email (vérifiez le dossier spam)</li>
|
||||||
<li>
|
<li>
|
||||||
|
{/* TODO: Magic link
|
||||||
Cliquez sur le lien pour vous connecter ou entrez le code à 6
|
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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -114,6 +116,7 @@ export default function Verify() {
|
||||||
otpError={otpError}
|
otpError={otpError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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" />
|
||||||
|
|
@ -121,7 +124,7 @@ export default function Verify() {
|
||||||
Un nouveau code a été envoyé à votre adresse email
|
Un nouveau code a été envoyé à votre adresse email
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
<div className="flex justify-between mt-4">
|
<div className="flex justify-between mt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -134,7 +137,7 @@ export default function Verify() {
|
||||||
Retour
|
Retour
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
{/* <Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -158,7 +161,7 @@ export default function Verify() {
|
||||||
Renvoyer le code
|
Renvoyer le code
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.6",
|
"@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/node": "^7.5.3",
|
||||||
"@react-router/serve": "^7.5.3",
|
"@react-router/serve": "^7.5.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
"lucide-react": "^0.511.0",
|
"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