This commit is contained in:
commit
b54d087a51
30 changed files with 6290 additions and 0 deletions
24
.forgejo/workflows/deploy.yml
Normal file
24
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: "Deploy to Netlify"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: "Deploy to Netlify"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
name: Checkout
|
||||
- name: Build
|
||||
run: |
|
||||
npm --version && node --version
|
||||
npm ci --no-update-notifier
|
||||
npm run build
|
||||
|
||||
- name: Deploy to Netlify
|
||||
run: npm run deploy
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/node_modules/
|
||||
.env
|
||||
design/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
87
README.md
Normal file
87
README.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Welcome to React Router!
|
||||
|
||||
A modern, production-ready template for building full-stack React applications using React Router.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Server-side rendering
|
||||
- ⚡️ Hot Module Replacement (HMR)
|
||||
- 📦 Asset bundling and optimization
|
||||
- 🔄 Data loading and mutations
|
||||
- 🔒 TypeScript by default
|
||||
- 🎉 TailwindCSS for styling
|
||||
- 📖 [React Router docs](https://reactrouter.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your application will be available at `http://localhost:5173`.
|
||||
|
||||
## Building for Production
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
To build and run using Docker:
|
||||
|
||||
```bash
|
||||
docker build -t my-app .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
The containerized application can be deployed to any platform that supports Docker, including:
|
||||
|
||||
- AWS ECS
|
||||
- Google Cloud Run
|
||||
- Azure Container Apps
|
||||
- Digital Ocean App Platform
|
||||
- Fly.io
|
||||
- Railway
|
||||
|
||||
### DIY Deployment
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||
├── build/
|
||||
│ ├── client/ # Static assets
|
||||
│ └── server/ # Server-side code
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using React Router.
|
||||
128
app/app.css
Normal file
128
app/app.css
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
70
app/components/input-otp.tsx
Normal file
70
app/components/input-otp.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { AlertCircleIcon, LoaderIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "./ui/alert";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||
import { verifyOtp } from "~/lib/api";
|
||||
|
||||
export default function OtpInput({
|
||||
email,
|
||||
setOtpError,
|
||||
otpError,
|
||||
}: {
|
||||
email: string;
|
||||
setOtpError: (error: string | null) => void;
|
||||
otpError: string | null;
|
||||
}) {
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
// Handle OTP verification
|
||||
const handleVerifyOtp = (otpCode: string) => {
|
||||
setIsVerifying(true);
|
||||
setOtpError(null);
|
||||
verifyOtp({ otpCode, email })
|
||||
.then(() => {
|
||||
setIsVerifying(false);
|
||||
// TODO: Proper redirect to success page
|
||||
})
|
||||
.catch((error) =>
|
||||
setOtpError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Code de vérification invalide"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
onComplete={(value) => handleVerifyOtp(value)}
|
||||
disabled={isVerifying}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
{isVerifying && (
|
||||
<div className="flex justify-center items-center py-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-primary mr-2" />
|
||||
<span className="text-sm">Vérification en cours...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otpError && (
|
||||
<Alert variant="destructive" className="mt-2 pb-2">
|
||||
<AlertCircleIcon className="h-4 w-4" />
|
||||
<AlertDescription>{otpError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
app/components/ui/alert.tsx
Normal file
58
app/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
55
app/components/ui/button.tsx
Normal file
55
app/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
85
app/components/ui/card.tsx
Normal file
85
app/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import * as React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
68
app/components/ui/input-otp.tsx
Normal file
68
app/components/ui/input-otp.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { Dot } from "lucide-react"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
InputOTP.displayName = "InputOTP"
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
))
|
||||
InputOTPGroup.displayName = "InputOTPGroup"
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
InputOTPSlot.displayName = "InputOTPSlot"
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Dot />
|
||||
</div>
|
||||
))
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
21
app/components/ui/input.tsx
Normal file
21
app/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
23
app/components/ui/label.tsx
Normal file
23
app/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
47
app/layout.tsx
Normal file
47
app/layout.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { UserLock } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./components/ui/card";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Link } from "react-router";
|
||||
|
||||
const SUPPORT_MAIL = "mailto:" + import.meta.env.VITE_SUPPORT_EMAIL;
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="flex h-full flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="w-full">
|
||||
<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
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Entrez votre email pour recevoir un lien de connexion ou un code
|
||||
de vérification.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
<CardFooter className="flex justify-between text-sm text-muted-foreground">
|
||||
<p className="text-xs">Besoin d'aide ?</p>
|
||||
<Button variant="link" size="sm" asChild className="h-auto p-0">
|
||||
<Link to={SUPPORT_MAIL} className="text-xs">
|
||||
Contactez le support
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
50
app/lib/api.ts
Normal file
50
app/lib/api.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
const makeRequest = async (
|
||||
url: string,
|
||||
body: object,
|
||||
error = "Une erreur est survenue",
|
||||
method = "POST"
|
||||
) => {
|
||||
const response = await fetch(BASE_URL + url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || error);
|
||||
return data?.data || data;
|
||||
};
|
||||
|
||||
export const requestLogin = async (email: string, token: string) => {
|
||||
return makeRequest(
|
||||
"/auth/request",
|
||||
{ email, token },
|
||||
"Échec de la demande de connexion"
|
||||
);
|
||||
};
|
||||
|
||||
export const verifyOtp = async ({
|
||||
otpCode,
|
||||
email,
|
||||
}: {
|
||||
otpCode: string;
|
||||
email: string;
|
||||
}) => {
|
||||
return makeRequest(
|
||||
"/auth/verify",
|
||||
{ email, code: otpCode },
|
||||
"Code de vérification invalide"
|
||||
);
|
||||
};
|
||||
|
||||
export const sendOtp = async (email: string) => {
|
||||
return makeRequest(
|
||||
"/auth/send-otp",
|
||||
{ email },
|
||||
"Échec de l'envoi du code de vérification"
|
||||
);
|
||||
};
|
||||
6
app/lib/utils.ts
Normal file
6
app/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
80
app/root.tsx
Normal file
80
app/root.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import "./app.css";
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
{/* Favicon */}
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "Ooups!";
|
||||
let details = "Une erreur est survenue.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Erreur";
|
||||
details =
|
||||
error.status === 404
|
||||
? "La page que vous cherchez n'existe pas."
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
7
app/routes.ts
Normal file
7
app/routes.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
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"),
|
||||
] satisfies RouteConfig;
|
||||
106
app/routes/login.tsx
Normal file
106
app/routes/login.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { Route } from "./+types/login";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { AlertCircleIcon, 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 { requestLogin } from "~/lib/api";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Khollisé - Connexion" },
|
||||
{ name: "description", content: "Connectez-vous à Khollisé" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const validateEmail = (email: string) => email.endsWith("@bginette.fr");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!validateEmail(email))
|
||||
return setError("Seules les adresses email @bginette.fr sont autorisées");
|
||||
if (!token)
|
||||
return setError("Veuillez valider le captcha avant de continuer");
|
||||
|
||||
setIsLoading(true);
|
||||
await requestLogin(email, token)
|
||||
.then((data) => {
|
||||
setIsLoading(false);
|
||||
const url = `/verify?email=${encodeURIComponent(email)}&token=${
|
||||
data?.token
|
||||
}`;
|
||||
navigate(url, {
|
||||
replace: true,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsLoading(false);
|
||||
setError(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<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"
|
||||
placeholder="prenom.nom@bginette.fr"
|
||||
className="pl-10"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Seules les adresses email{" "}
|
||||
<span className="font-bold">@bginette.fr</span> sont acceptées.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="pb-2">
|
||||
<AlertCircleIcon className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Turnstile siteKey={siteKey} onSuccess={setToken} />
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoaderCircle className="h-4 w-4 animate-spin mr-2" />
|
||||
Connexion en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="h-4 w-4 mr-2" />
|
||||
Se connecter
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
132
app/routes/success.tsx
Normal file
132
app/routes/success.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { Route } from "./+types/success";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
MonitorSmartphone,
|
||||
} from "lucide-react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Khollisé - Connexion" },
|
||||
{ name: "description", content: "Connexion réussie à Khollisé" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function AuthSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [email, redirect, code] = ["email", "redirect", "code"].map((param) =>
|
||||
searchParams.get(param)
|
||||
);
|
||||
const [countdown, setCountdown] = useState(5000);
|
||||
|
||||
// Valider l'URL de redirection
|
||||
const redirectUrl = "/home";
|
||||
// isValidRedirectUrl(redirectParam) ? redirectParam : "/accueil"
|
||||
|
||||
useEffect(() => {
|
||||
// Démarrer le compte à rebours pour la redirection
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
navigate(redirectUrl);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [redirectUrl, navigate]);
|
||||
|
||||
return (
|
||||
<main className="flex h-full flex-col items-center justify-center p-4 bg-gray-50">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="w-full overflow-hidden">
|
||||
<div className="bg-green-500 h-2" />
|
||||
<CardHeader className="space-y-1 pb-6">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="rounded-full bg-green-100 p-3">
|
||||
<CheckCircleIcon className="h-10 w-10 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Authentification réussie
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Vous êtes maintenant connecté en tant que
|
||||
<span className="font-bold"> {email}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg border border-green-100">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-800">
|
||||
Cet appareil a été authentifié avec succès
|
||||
</p>
|
||||
<p className="text-xs text-green-700 mt-1">
|
||||
Vous êtes maintenant connecté et pouvez accéder à votre
|
||||
compte.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<MonitorSmartphone className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800">
|
||||
Authentification multi-appareils
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
Si vous avez initié cette connexion depuis un autre
|
||||
appareil, celui-ci a également été authentifié.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<p className="text-sm text-center text-gray-500">
|
||||
Vous allez être redirigé dans{" "}
|
||||
<span className="font-bold">{countdown}</span> secondes...
|
||||
</p>
|
||||
<Button asChild className="w-full">
|
||||
<Link
|
||||
to={redirectUrl}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
Continuer maintenant
|
||||
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500">Besoin d'aide ?</p>
|
||||
<Button variant="link" size="sm" asChild className="h-auto p-0">
|
||||
<Link to="/support" className="text-xs">
|
||||
Contacter le support
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
167
app/routes/verify.tsx
Normal file
167
app/routes/verify.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import type { Route } from "./+types/verify";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircleIcon,
|
||||
LoaderIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useSearchParams } from "react-router";
|
||||
import OtpInput from "~/components/input-otp";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import AuthLayout from "~/layout";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Khollisé - Connexion" },
|
||||
{ name: "description", content: "Connectez-vous à Khollisé" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Verify() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams()
|
||||
const email = searchParams.get("email");
|
||||
if (!email) {
|
||||
// TODO: Redirect to login page
|
||||
return
|
||||
}
|
||||
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [resendSuccess, setResendSuccess] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
|
||||
// Countdown for the resend button
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCooldown((prev) => Math.max(0, prev - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
const handleResendOtp = async () => {
|
||||
if (cooldown > 0) return;
|
||||
|
||||
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 }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className="space-y-4">
|
||||
<Alert className="bg-green-50 border-green-200 pb-2">
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-green-600">
|
||||
Nous avons envoyé un email à <strong>{email}</strong>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<h3 className="font-medium">Que se passe-t-il ensuite ?</h3>
|
||||
<ul className="list-disc pl-5 space-y-1 text-sm">
|
||||
<li>Consultez votre email (vérifiez le dossier spam)</li>
|
||||
<li>
|
||||
Cliquez sur le lien pour vous connecter ou entrez le code à 6
|
||||
chiffres
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<h3 className="text-sm text-center font-medium mb-2">
|
||||
Entrez le code de vérification
|
||||
</h3>
|
||||
<OtpInput
|
||||
email={email}
|
||||
setOtpError={setOtpError}
|
||||
otpError={otpError}
|
||||
/>
|
||||
|
||||
{resendSuccess && (
|
||||
<Alert className="bg-green-50 border-green-200 mt-2">
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-green-600">
|
||||
Un nouveau code a été envoyé à votre adresse email
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
<Button
|
||||
className="flex items-center text-sm"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Retour
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleResendOtp}
|
||||
disabled={cooldown > 0 || isResending}
|
||||
className="flex items-center text-sm"
|
||||
>
|
||||
{isResending ? (
|
||||
<>
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
Envoi en cours...
|
||||
</>
|
||||
) : cooldown > 0 ? (
|
||||
<>
|
||||
<RefreshCwIcon className="h-4 w-4" />
|
||||
Renvoyer le code ({cooldown}s)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCwIcon className="h-4 w-4" />
|
||||
Renvoyer le code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
4984
package-lock.json
generated
Normal file
4984
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
package.json
Normal file
39
package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "khollise-auth",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev --host",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"deploy": "netlify deploy --prod --dir=./build/client --message=\"Deploy to Netlify from Forgejo\" --site=$NETLIFY_SITE_ID --auth=$NETLIFY_AUTH_TOKEN"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@react-router/node": "^7.5.3",
|
||||
"@react-router/serve": "^7.5.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.5.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tw-animate-css": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.5.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
public/favicon-96x96.png
Normal file
BIN
public/favicon-96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
3
public/favicon.svg
Normal file
3
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 2 MiB |
BIN
public/web-app-manifest-192x192.png
Normal file
BIN
public/web-app-manifest-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
BIN
public/web-app-manifest-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 393 KiB |
7
react-router.config.ts
Normal file
7
react-router.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"include": [
|
||||
"**/*",
|
||||
"**/.server/**/*",
|
||||
"**/.client/**/*",
|
||||
".react-router/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue