mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge pull request #3 from Since-3/feature/registered-user
added register feature for user with db safe
This commit is contained in:
commit
07aa278aee
34 changed files with 2606 additions and 25 deletions
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
|
@ -6,6 +6,12 @@ on:
|
|||
pull_request:
|
||||
branches: [dev]
|
||||
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build, Lint, Test
|
||||
|
|
@ -24,6 +30,9 @@ jobs:
|
|||
- name: 📦 Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: 🔧 Generate Prisma Client
|
||||
run: npx prisma generate
|
||||
|
||||
- name: 🔍 Lint code
|
||||
run: npm run lint --workspaces=false
|
||||
|
||||
|
|
|
|||
1236
package-lock.json
generated
1236
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -14,6 +14,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -21,10 +24,15 @@
|
|||
"dotenv": "^17.2.3",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "16.0.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
7
src/app/(auth)/login/page.tsx
Normal file
7
src/app/(auth)/login/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import LoginView from "@/src/modules/auth/ui/views/login-view";
|
||||
|
||||
const Login = () => {
|
||||
return <LoginView />;
|
||||
};
|
||||
|
||||
export default Login;
|
||||
7
src/app/(auth)/register/admin/page.tsx
Normal file
7
src/app/(auth)/register/admin/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import RegisterAdminView from "@/src/modules/auth/ui/views/register-admin-view";
|
||||
|
||||
const RegisterAdmin = () => {
|
||||
return <RegisterAdminView />;
|
||||
};
|
||||
|
||||
export default RegisterAdmin;
|
||||
7
src/app/(auth)/register/user/page.tsx
Normal file
7
src/app/(auth)/register/user/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import RegisterUserView from "@/src/modules/auth/ui/views/register-user-view";
|
||||
|
||||
const RegisterUser = () => {
|
||||
return <RegisterUserView />;
|
||||
};
|
||||
|
||||
export default RegisterUser;
|
||||
5
src/app/(main)/dashboard/page.tsx
Normal file
5
src/app/(main)/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const Dashboard = () => {
|
||||
return <div>Du hast dich erfolgreich eingeloggt</div>;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
71
src/app/api/auth/register/route.ts
Normal file
71
src/app/api/auth/register/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { supabaseServer } from "@/src/lib/supabaseClient";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
** Handles POST request to register and save a User in the DB
|
||||
*
|
||||
*
|
||||
* @param {Request} request - Incoming HTTP request containing user data in JSON format
|
||||
* @returns {Promise<NextResponse>} - JSON response with exisiting or newly created user
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password, name, birthday, plz, city, street, houseNumber, gender } = body;
|
||||
|
||||
if (!email || !password || !name) {
|
||||
return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabaseServer.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: "http://localhost:3000/login",
|
||||
data: {
|
||||
name,
|
||||
gender,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("❌ Supabase Auth Error:", error.message);
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabaseUser = data.user;
|
||||
if (!supabaseUser) {
|
||||
return NextResponse.json({ error: "User konnte nicht erstellt werden" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Check if User exists in the DB
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ user: existingUser }, { status: 200 });
|
||||
}
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
id: supabaseUser.id,
|
||||
name,
|
||||
email,
|
||||
birthday: birthday ? new Date(birthday) : null,
|
||||
plz,
|
||||
city,
|
||||
street,
|
||||
houseNumber,
|
||||
gender,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ user: newUser }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error in /api/auth/register:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,9 @@
|
|||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-white: #ffffff;
|
||||
--color-blue: #1e293b;
|
||||
--color-yellow: #f4c00c;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
const salmondFont = localFont({
|
||||
src: "../public/font/salmond.woff",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -23,8 +18,18 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
|
||||
<html lang="en" className={salmondFont.className}>
|
||||
<body className={`antialiased`}>
|
||||
<main>{children}</main>
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "#1E293B",
|
||||
color: "#F4C00C",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
57
src/components/layout/DropdownComponent.tsx
Normal file
57
src/components/layout/DropdownComponent.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
interface DropdownComponentProps {
|
||||
label: string;
|
||||
selected: string;
|
||||
onSelect: (value: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DropdownComponent: React.FC<DropdownComponentProps> = ({
|
||||
label,
|
||||
selected,
|
||||
onSelect,
|
||||
error,
|
||||
}) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="relative w-full">
|
||||
<Label className="p-1 mb-2 text-blue text-lg font-semibold">{label}</Label>
|
||||
<Input
|
||||
className="h-[50px] border-blue rounded-xl w-full cursor-pointer pr-10"
|
||||
value={selected}
|
||||
readOnly
|
||||
/>
|
||||
<ChevronDown
|
||||
className="absolute right-3 top-[75%] transform -translate-y-1/2 text-blue pointer-events-none"
|
||||
size={24}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={() => onSelect("")} className="text-gray-500">
|
||||
Auswahl löschen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSelect("Men")}>Männlich</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSelect("Woman")}>Weiblich</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSelect("Various")}>Divers</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownComponent;
|
||||
45
src/components/layout/InputComponent.tsx
Normal file
45
src/components/layout/InputComponent.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Label } from "../ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
interface InputProps {
|
||||
label?: string;
|
||||
type: string;
|
||||
id: string;
|
||||
isLabel?: boolean;
|
||||
placeholder?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
value?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const InputComponent: React.FC<InputProps> = ({
|
||||
label,
|
||||
type,
|
||||
id,
|
||||
placeholder,
|
||||
isLabel,
|
||||
onChange,
|
||||
value,
|
||||
error,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
{isLabel && (
|
||||
<Label htmlFor={type} className="p-1 mb-2 text-blue text-lg font-semibold">
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<Input
|
||||
type={type}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
className="h-[50px] border-blue rounded-xl w-full"
|
||||
/>
|
||||
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputComponent;
|
||||
|
|
@ -9,13 +9,14 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default:
|
||||
"bg-blue text-yellow font-bold text-md drop-shadow-lg hover:scale-105 transition-all cursor-pointer",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
|
|
@ -25,8 +26,6 @@ const buttonVariants = cva(
|
|||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/src/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
32
src/components/ui/checkbox.tsx
Normal file
32
src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/src/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/src/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/src/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-lg",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/src/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
40
src/components/ui/sonner.tsx
Normal file
40
src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
16
src/components/ui/spinner.tsx
Normal file
16
src/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/src/lib/utils"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
|
|
@ -1,6 +1,18 @@
|
|||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export const supabaseBrowser = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
|
||||
throw new Error(
|
||||
"❌ Missing Supabase environment variables! Make sure NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set."
|
||||
);
|
||||
}
|
||||
|
||||
export const supabaseServer = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
|
|
|||
74
src/modules/auth/store/authFormState.ts
Normal file
74
src/modules/auth/store/authFormState.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
interface UserRegisterFormState {
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
birthday: string;
|
||||
plz: string;
|
||||
place: string;
|
||||
street: string;
|
||||
gender: string;
|
||||
role: "user" | "admin" | "";
|
||||
}
|
||||
|
||||
interface AdminRegisterFormState {
|
||||
tel: string;
|
||||
companyName: string;
|
||||
companyPlace: string;
|
||||
companyPLZ: string;
|
||||
companyStreet: string;
|
||||
companyMail: string;
|
||||
companyNumber: string;
|
||||
}
|
||||
|
||||
interface AuthFormState extends UserRegisterFormState, AdminRegisterFormState {
|
||||
resetForm: () => void;
|
||||
setField: (field: string, value: string) => void;
|
||||
}
|
||||
|
||||
export const useAuthFormStore = create<AuthFormState>((set) => ({
|
||||
// Common + User fields
|
||||
fullName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
birthday: "",
|
||||
plz: "",
|
||||
place: "",
|
||||
street: "",
|
||||
gender: "",
|
||||
role: "",
|
||||
// Admin fields
|
||||
tel: "",
|
||||
companyName: "",
|
||||
companyPlace: "",
|
||||
companyPLZ: "",
|
||||
companyStreet: "",
|
||||
companyMail: "",
|
||||
companyNumber: "",
|
||||
|
||||
setField: (field, value) => set((state) => ({ ...state, [field]: value })),
|
||||
|
||||
resetForm: () =>
|
||||
set({
|
||||
fullName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
birthday: "",
|
||||
plz: "",
|
||||
place: "",
|
||||
street: "",
|
||||
gender: "",
|
||||
role: "",
|
||||
tel: "",
|
||||
companyName: "",
|
||||
companyPlace: "",
|
||||
companyPLZ: "",
|
||||
companyStreet: "",
|
||||
companyMail: "",
|
||||
companyNumber: "",
|
||||
}),
|
||||
}));
|
||||
15
src/modules/auth/ui/components/AuthButton.tsx
Normal file
15
src/modules/auth/ui/components/AuthButton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
interface AuthButtonProps {
|
||||
Icon: React.ComponentType<{ size?: number }>;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const AuthButton: React.FC<AuthButtonProps> = ({ Icon, label }) => {
|
||||
return (
|
||||
<div className="flex gap-2 py-2 px-3 items-center justify-center rounded-xl border-1 border-blue text-blue w-full sm:w-fit cursor-pointer hover:scale-105 transition-all">
|
||||
<Icon size={20} />
|
||||
<p className="ml-2 mt-1">{label}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthButton;
|
||||
48
src/modules/auth/ui/components/AuthSidebarComponent.tsx
Normal file
48
src/modules/auth/ui/components/AuthSidebarComponent.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import Image from "next/image";
|
||||
import MultiStepProzess from "./MultiStepProzess";
|
||||
import Logo from "@/src/public/logo.png";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/src/components/ui/card";
|
||||
|
||||
interface AuthSidebarComponentProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
footer: string;
|
||||
isSteps?: boolean;
|
||||
step?: number;
|
||||
totalSteps?: number;
|
||||
onStepChange?: (step: number) => void;
|
||||
}
|
||||
|
||||
const AuthSidebarComponent: React.FC<AuthSidebarComponentProps> = ({
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
isSteps,
|
||||
step = 1,
|
||||
totalSteps = 2,
|
||||
onStepChange,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="bg-blue border-0 w-fit h-full p-6 sm:p-12 flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Image src={Logo} width={150} height={150} alt="Logo" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-yellow flex flex-col gap-12 min-h-[70vh]">
|
||||
<h1 className="text-3xl lg:text-[40px] max-w-[300px] font-bold mt-8 lg:mt-12">{title}</h1>
|
||||
<p className="text-base">{description}</p>
|
||||
{isSteps && (
|
||||
<MultiStepProzess step={step} totalSteps={totalSteps} onStepChange={onStepChange} />
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-yellow">
|
||||
Bei Fragen: <span className=" cursor-pointer hover:underline">{footer}</span>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthSidebarComponent;
|
||||
37
src/modules/auth/ui/components/MultiStepProzess.tsx
Normal file
37
src/modules/auth/ui/components/MultiStepProzess.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React from "react";
|
||||
import MultiStepProzessItem from "./MultiStepProzessItem";
|
||||
|
||||
interface MultiStepProzessProps {
|
||||
step: number;
|
||||
totalSteps: number;
|
||||
onStepChange?: (step: number) => void;
|
||||
}
|
||||
|
||||
const MultiStepProzess: React.FC<MultiStepProzessProps> = ({ step, totalSteps, onStepChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<MultiStepProzessItem
|
||||
stepTitle="Schritt 1"
|
||||
stepDescription="Anmeldedaten"
|
||||
isActive={step == 1}
|
||||
onClick={() => onStepChange?.(1)}
|
||||
/>
|
||||
<MultiStepProzessItem
|
||||
stepTitle="Schritt 2"
|
||||
stepDescription="Persönliche Angaben"
|
||||
isActive={step == 2}
|
||||
onClick={() => onStepChange?.(2)}
|
||||
/>
|
||||
{totalSteps == 3 && (
|
||||
<MultiStepProzessItem
|
||||
stepTitle="Schritt 3"
|
||||
stepDescription="Geschäftsdaten"
|
||||
isActive={step == 3}
|
||||
onClick={() => onStepChange?.(3)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiStepProzess;
|
||||
30
src/modules/auth/ui/components/MultiStepProzessItem.tsx
Normal file
30
src/modules/auth/ui/components/MultiStepProzessItem.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
interface MultiStepProzessItemProps {
|
||||
stepTitle: string;
|
||||
stepDescription: string;
|
||||
isActive: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const MultiStepProzessItem: React.FC<MultiStepProzessItemProps> = ({
|
||||
stepTitle,
|
||||
stepDescription,
|
||||
isActive,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div onClick={onClick} className="w-full flex items-center cursor-pointer gap-4 mt-6">
|
||||
<div
|
||||
className={`h-full flex justify-center items-center rounded-full p-5 ${isActive ? "bg-yellow" : "bg-transparent"} border-2 border-yellow`}
|
||||
>
|
||||
<div className={`w-full p-1 rounded-full ${isActive ? "bg-blue" : "bg-yellow"}`}></div>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col text-lg justify-center ${isActive && "font-semibold"}`}>
|
||||
<h4>{stepTitle}</h4>
|
||||
<p>{stepDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiStepProzessItem;
|
||||
133
src/modules/auth/ui/views/login-view.tsx
Normal file
133
src/modules/auth/ui/views/login-view.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
import InputComponent from "@/src/components/layout/InputComponent";
|
||||
import AuthButton from "../components/AuthButton";
|
||||
import AuthSidebarComponent from "../components/AuthSidebarComponent";
|
||||
import { FaGoogle, FaFacebook } from "react-icons/fa";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabaseBrowser } from "@/src/lib/supabaseClient";
|
||||
import { Spinner } from "@/src/components/ui/spinner";
|
||||
|
||||
const LoginView = () => {
|
||||
//TODO: RIGHT LOGIN with Schema etc.
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabaseBrowser.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("❌ Login Error:", error.message);
|
||||
setError("E-Mail oder Passwort ist falsch");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional: Session speichern
|
||||
if (remember) {
|
||||
localStorage.setItem("since3_remember_me", "true");
|
||||
} else {
|
||||
localStorage.removeItem("since3_remember_me");
|
||||
}
|
||||
|
||||
// Redirect nach Login
|
||||
router.push("/dashboard");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Ein unerwarteter Fehler ist aufgetreten");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<div className="hidden lg:flex lg:w-1/6 ">
|
||||
<div className="fixed p-4 left-0 top-0 h-screen w-1/2 z-10">
|
||||
<AuthSidebarComponent
|
||||
title="Deine Reise beginnt hier"
|
||||
description="Werde Teil einer Plattform, die Trainer und Lernende zusammenbringt."
|
||||
footer="info@since3.de"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login-Formular */}
|
||||
<div className="w-full lg:w-5/6 lg:ml-auto flex justify-center items-center min-h-screen px-4">
|
||||
<div className="w-full max-w-lg flex flex-col gap-4 z-20">
|
||||
<h1 className="text-2xl font-extrabold text-blue mb-8 ">Login</h1>
|
||||
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="E-mail Adresse"
|
||||
type="email"
|
||||
id="login-email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Passwort"
|
||||
type="password"
|
||||
id="login-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button onClick={handleLogin} disabled={loading} className="w-full mt-6">
|
||||
{loading ? <Spinner /> : "Weiter"}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between w-full mt-6 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="remembering"
|
||||
checked={remember}
|
||||
onCheckedChange={(val: boolean) => setRemember(val)}
|
||||
className="shadow-none h-5 w-5 bg-white border-blue cursor-pointer"
|
||||
/>
|
||||
<Label htmlFor="remembering" className="cursor-pointer">
|
||||
Beim nächsten Mal an mich erinnern
|
||||
</Label>
|
||||
</div>
|
||||
<p className="hover:underline cursor-pointer text-blue">Passwort vergessen?</p>
|
||||
</div>
|
||||
|
||||
<hr className="mt-6 border-blue" />
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mt-4">
|
||||
<AuthButton Icon={FaGoogle} label="Login mit Google" />
|
||||
<AuthButton Icon={FaFacebook} label="Login mit Facebook" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-center mt-16 text-sm">
|
||||
<p>Ich habe noch keinen Account</p>
|
||||
<p
|
||||
className="text-yellow hover:underline cursor-pointer"
|
||||
onClick={() => router.push("/register")}
|
||||
>
|
||||
Sign Up
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginView;
|
||||
7
src/modules/auth/ui/views/register-admin-view.tsx
Normal file
7
src/modules/auth/ui/views/register-admin-view.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
const RegisterAdminView = () => {
|
||||
return <div>Register Admin View</div>;
|
||||
};
|
||||
|
||||
export default RegisterAdminView;
|
||||
251
src/modules/auth/ui/views/register-user-view.tsx
Normal file
251
src/modules/auth/ui/views/register-user-view.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import AuthSidebarComponent from "../components/AuthSidebarComponent";
|
||||
import InputComponent from "@/src/components/layout/InputComponent";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import AuthButton from "../components/AuthButton";
|
||||
import { FaGoogle, FaFacebook } from "react-icons/fa";
|
||||
import { useAuthFormStore } from "../../store/authFormState";
|
||||
import { userRegisterSchema, userRegisterStep1Schema } from "../../validation/userRegisterSchema";
|
||||
import { ZodError } from "zod";
|
||||
import DropdownComponent from "@/src/components/layout/DropdownComponent";
|
||||
import { Spinner } from "@/src/components/ui/spinner";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const RegisterUserView = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const formData = useAuthFormStore();
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
formData.setField(field, value);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setErrors({});
|
||||
try {
|
||||
if (step === 1) {
|
||||
userRegisterStep1Schema.parse({
|
||||
fullName: formData.fullName,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
passwordConfirm: formData.passwordConfirm,
|
||||
});
|
||||
setStep(2);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
err.issues.forEach((issue) => {
|
||||
const path = issue.path[0];
|
||||
if (typeof path === "string") {
|
||||
fieldErrors[path] = issue.message;
|
||||
}
|
||||
});
|
||||
setErrors(fieldErrors);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
setErrors({});
|
||||
setLoading(true);
|
||||
try {
|
||||
userRegisterSchema.parse({
|
||||
fullName: formData.fullName,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
passwordConfirm: formData.passwordConfirm,
|
||||
birthday: formData.birthday,
|
||||
plz: formData.plz,
|
||||
place: formData.place,
|
||||
street: formData.street,
|
||||
gender: formData.gender,
|
||||
});
|
||||
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
name: formData.fullName,
|
||||
birthday: formData.birthday,
|
||||
plz: formData.plz,
|
||||
city: formData.place,
|
||||
street: formData.street,
|
||||
houseNumber: "",
|
||||
gender: formData.gender,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setErrors({ email: data.error || "Registrierung fehlgeschlagen" });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info("Bitte Email bestätigen für erfolgreiche Registrierung");
|
||||
formData.resetForm();
|
||||
setStep(1);
|
||||
setErrors({});
|
||||
|
||||
router.push("/login");
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
err.issues.forEach((issue) => {
|
||||
const path = issue.path[0];
|
||||
if (typeof path === "string") {
|
||||
fieldErrors[path] = issue.message;
|
||||
}
|
||||
});
|
||||
setErrors(fieldErrors);
|
||||
} else {
|
||||
toast.error(`Registrierung fehlgeschlagen: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screena">
|
||||
{/* Sidebar */}
|
||||
<div className="hidden lg:flex lg:w-1/6 ">
|
||||
<div className="fixed p-4 left-0 top-0 h-screen w-1/2 z-10">
|
||||
<AuthSidebarComponent
|
||||
title="Werde zur besten Version deiner selbst"
|
||||
footer="info@since3.de"
|
||||
isSteps
|
||||
step={step}
|
||||
totalSteps={2}
|
||||
onStepChange={setStep}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-5/6 lg:ml-auto flex justify-center items-center min-h-screen px-4">
|
||||
<div className="w-full max-w-lg flex flex-col gap-4 z-20">
|
||||
<h1 className="text-2xl font-extrabold text-blue mb-8 mt-8 ">Sign up for free</h1>
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Vor- und Nachname"
|
||||
type="text"
|
||||
id="register-user-fullName"
|
||||
value={formData.fullName}
|
||||
onChange={(e) => handleChange("fullName", e.target.value)}
|
||||
error={errors.fullName}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
id="register-user-email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange("email", e.target.value)}
|
||||
error={errors.email}
|
||||
/>
|
||||
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Passwort"
|
||||
type="password"
|
||||
id="register-user-password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange("password", e.target.value)}
|
||||
error={errors.password}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Passwort bestätigen"
|
||||
type="password"
|
||||
id="register-user-passwordConform"
|
||||
value={formData.passwordConfirm}
|
||||
onChange={(e) => handleChange("passwordConfirm", e.target.value)}
|
||||
error={errors.passwordConfirm}
|
||||
/>
|
||||
|
||||
<Button className="w-full mt-6" onClick={handleNext}>
|
||||
Weiter
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Geburtsdatum"
|
||||
type="date"
|
||||
id="register-user-birthday"
|
||||
value={formData.birthday}
|
||||
onChange={(e) => handleChange("birthday", e.target.value)}
|
||||
error={errors.birthday}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="PLZ"
|
||||
type="text"
|
||||
id="register-user-plz"
|
||||
value={formData.plz}
|
||||
onChange={(e) => handleChange("plz", e.target.value)}
|
||||
error={errors.plz}
|
||||
/>
|
||||
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Wohnort"
|
||||
type="text"
|
||||
id="register-user-place"
|
||||
value={formData.place}
|
||||
onChange={(e) => handleChange("place", e.target.value)}
|
||||
error={errors.place}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Straße und Hausnummer"
|
||||
type="text"
|
||||
id="register-user-street"
|
||||
value={formData.street}
|
||||
onChange={(e) => handleChange("street", e.target.value)}
|
||||
error={errors.street}
|
||||
/>
|
||||
|
||||
<DropdownComponent
|
||||
label="Geschlecht"
|
||||
selected={formData.gender}
|
||||
onSelect={(value: string) => handleChange("gender", value)}
|
||||
error={errors.gender}
|
||||
/>
|
||||
|
||||
<Button onClick={handleRegister} disabled={loading} className="w-full mt-6">
|
||||
{loading ? <Spinner /> : "Anmelden"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr className="mt-6 border-blue" />
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mt-4">
|
||||
<AuthButton Icon={FaGoogle} label="Login mit Google" />
|
||||
<AuthButton Icon={FaFacebook} label="Login mit Facebook" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-center mt-8 text-sm">
|
||||
<p>Ich habe schon einen Account</p>
|
||||
<p className="text-yellow hover:underline cursor-pointer">Login</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterUserView;
|
||||
0
src/modules/auth/validation/adminRegisterSchema.ts
Normal file
0
src/modules/auth/validation/adminRegisterSchema.ts
Normal file
0
src/modules/auth/validation/loginSchema.ts
Normal file
0
src/modules/auth/validation/loginSchema.ts
Normal file
50
src/modules/auth/validation/userRegisterSchema.ts
Normal file
50
src/modules/auth/validation/userRegisterSchema.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const userRegisterStep1Schema = z
|
||||
.object({
|
||||
fullName: z.string().min(2, "Name ist zu kurz"),
|
||||
email: z.string().email("Ungültige E-Mail-Adresse"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8)
|
||||
.regex(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[.@$!%*?&])[A-Za-z\d.@$!%*?&]+$/,
|
||||
"Passwort muss mindestens 8 Zeichen haben, Großbuchstaben, Kleinbuchstaben, Nummer und ein spezielles Zeichen beinhalten"
|
||||
),
|
||||
passwordConfirm: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirm, {
|
||||
message: "Passwörter stimmen nicht überein",
|
||||
path: ["passwordConfirm"],
|
||||
});
|
||||
|
||||
export const userRegisterSchema = z
|
||||
.object({
|
||||
fullName: z.string().min(2, "Name ist zu kurz"),
|
||||
email: z.string().email("Ungültige E-Mail-Adresse"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8)
|
||||
.regex(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[.@$!%*?&])[A-Za-z\d.@$!%*?&]+$/,
|
||||
"Passwort muss mindestens 8 Zeichen haben, Großbuchstaben, Kleinbuchstaben, Nummer und ein spezielles Zeichen beinhalten"
|
||||
),
|
||||
passwordConfirm: z.string(),
|
||||
birthday: z.string().min(1, "Geburtsdatum erforderlich"),
|
||||
plz: z.string().min(1, "PLZ erforderlich"),
|
||||
place: z.string().min(1, "Wohnort erforderlich"),
|
||||
street: z.string().min(1, "Straße erforderlich"),
|
||||
gender: z
|
||||
.enum(["Men", "Woman", "Various"])
|
||||
.or(z.literal(""))
|
||||
.refine((val) => val !== "", {
|
||||
message: "Geschlecht ist erforderlich",
|
||||
}),
|
||||
role: z.literal("user").optional(),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirm, {
|
||||
message: "Passwörter stimmen nicht überein",
|
||||
path: ["passwordConfirm"],
|
||||
});
|
||||
|
||||
export type UserRegisterSchema = z.infer<typeof userRegisterSchema>;
|
||||
BIN
src/public/font/salmond.woff
Normal file
BIN
src/public/font/salmond.woff
Normal file
Binary file not shown.
BIN
src/public/logo.png
Normal file
BIN
src/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Loading…
Add table
Add a link
Reference in a new issue