feat: add signin page with functionality

This commit is contained in:
arraysid
2025-05-19 19:01:55 +07:00
parent 50cc24e46f
commit b04f687199
22 changed files with 691 additions and 56 deletions
@@ -0,0 +1,86 @@
'use client';
import { motion } from 'framer-motion';
import { useEffect, useMemo, useState } from 'react';
import {
SiCplusplus,
SiCss3,
SiGo,
SiHtml5,
SiJavascript,
SiPhp,
SiPython,
SiRuby,
SiRust,
SiSwift,
SiTypescript,
} from 'react-icons/si';
export function AnimatedBackground() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const iconColorMap = useMemo(
() => [
{ Icon: SiJavascript, color: '#F7DF1E' },
{ Icon: SiTypescript, color: '#3178C6' },
{ Icon: SiPython, color: '#3776AB' },
{ Icon: SiCplusplus, color: '#00599C' },
{ Icon: SiRuby, color: '#CC342D' },
{ Icon: SiSwift, color: '#F05138' },
{ Icon: SiRust, color: '#000000' },
{ Icon: SiGo, color: '#00ADD8' },
{ Icon: SiPhp, color: '#777BB4' },
{ Icon: SiHtml5, color: '#E34F26' },
{ Icon: SiCss3, color: '#1572B6' },
],
[]
);
const getRandom = (min: number, max: number) =>
Math.random() * (max - min) + min;
const floatingIcons = useMemo(() => {
if (!isClient) return [];
return Array.from({ length: 40 }).map((_, i) => {
const { Icon, color } = iconColorMap[i % iconColorMap.length];
return (
<motion.div
key={i}
className="pointer-events-none absolute"
style={{
top: `${getRandom(0, 100)}%`,
left: `${getRandom(0, 100)}%`,
fontSize: `${getRandom(16, 32)}px`,
color: color,
opacity: getRandom(0.1, 0.2),
rotate: getRandom(-180, 180),
}}
animate={{
y: [0, getRandom(-100, 100), 0],
x: [0, getRandom(-50, 50), 0],
rotate: getRandom(-180, 180),
}}
transition={{
duration: getRandom(15, 25),
repeat: Infinity,
repeatType: 'loop',
ease: 'easeInOut',
}}
>
<Icon className="h-full w-full" />
</motion.div>
);
});
}, [isClient, iconColorMap]);
if (!isClient) return null;
return (
<div className="fixed inset-0 z-0 overflow-hidden">{floatingIcons}</div>
);
}
@@ -1,5 +1,19 @@
import { Card } from '@components/atoms';
import { ReactNode } from 'react';
import { AnimatedBackground } from './_components/animated-background';
export default function Layout({ children }: { children: ReactNode }) {
return <>{children}</>;
return (
<div className="bg-background/20 relative flex min-h-[100dvh] items-center justify-center p-4">
<AnimatedBackground />
<div className="z-10 w-full">
<Card className="bg-background/90 mx-auto w-full max-w-[360px] p-6 shadow-xl backdrop-blur-lg sm:max-w-md sm:p-8">
<div className="flex flex-col items-center space-y-6">
<div className="w-full space-y-4">{children}</div>
</div>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,29 @@
'use server';
import { setAccessToken, setRefreshToken } from '@/lib/cookies';
import { fetcher } from '@/lib/openapi';
export async function SigninAction({
email,
password,
}: {
email: string;
password: string;
}) {
const { data: json, error } = await fetcher.POST('/v1/auth/login', {
body: {
email,
password,
},
});
if (error) throw new Error(error.message);
const accessToken = json.data.token.access_token;
const refreshToken = json.data.token.refresh_token;
await setAccessToken(accessToken);
await setRefreshToken(refreshToken);
return json;
}
@@ -0,0 +1,92 @@
'use client';
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@components/atoms';
import { zodResolver } from '@hookform/resolvers/zod';
import { authLoginSchema } from '@schemas';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { LuLoader } from 'react-icons/lu';
import { z } from 'zod';
import { SigninAction } from '../_action/signin-action';
type SigninData = z.infer<typeof authLoginSchema>;
export function SigninForm() {
const router = useRouter();
const form = useForm<SigninData>({
resolver: zodResolver(authLoginSchema),
defaultValues: {
email: '',
password: '',
},
});
const { mutate, isPending, error } = useMutation({
mutationFn: SigninAction,
onSuccess: () => {
router.push('/');
},
onError: () => {
form.resetField('password');
},
});
const onSubmit = (values: SigninData) => {
mutate(values);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="mb-6 p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
{(error as Error).message}
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? <LuLoader className="h-5 w-5 animate-spin" /> : 'Masuk'}
</Button>
</form>
</Form>
);
}
@@ -1,3 +1,38 @@
import { Metadata } from 'next';
import Link from 'next/link';
import { LogoSimple } from '../../_components/logo';
import { SigninForm } from './_components/signin-form';
export const metadata: Metadata = {
title: 'IMPHNEN - Signin',
description:
'Komunitas Ingin Menjadi Programmer Handal Namung Enggan Ngonding',
};
export default function Page() {
return <></>;
return (
<>
<div className="flex flex-col items-center gap-4">
<LogoSimple />
<p className="text-muted-foreground text-center text-sm sm:text-base">
Masuk untuk mengakses akunmu
</p>
</div>
<SigninForm />
<div className="w-full space-y-4">
<p className="text-muted-foreground px-4 text-center text-xs leading-5 text-balance sm:text-sm">
Belum punya akun?{' '}
<Link
href="/signup"
className="text-primary font-bold hover:underline hover:underline-offset-4 transition-colors"
>
Daftar
</Link>
</p>
</div>
</>
);
}
@@ -1,3 +1,15 @@
import { LogoSimple } from '../../_components/logo';
export default function Page() {
return <></>;
return (
<>
<div className="flex flex-col items-center gap-4">
<LogoSimple />
<p className="text-muted-foreground text-center text-sm sm:text-base">
Buat akunmu sekarang
</p>
</div>
</>
);
}
@@ -1,7 +1,8 @@
'use client';
import { useMobileMenuStore } from '@/stores/mobile-menu-store';
import { Button, MenuIcon, XIcon } from '@components/atoms';
import { Button } from '@components/atoms';
import { LuMenu, LuX } from 'react-icons/lu';
export function MobileMenuHamburger() {
const mobileMenuOpen = useMobileMenuStore((s) => s.mobileMenuOpen);
@@ -15,9 +16,9 @@ export function MobileMenuHamburger() {
onClick={toggleMobileMenu}
>
{mobileMenuOpen ? (
<XIcon className="h-6 w-6" />
<LuX className="h-6 w-6" />
) : (
<MenuIcon className="h-6 w-6" />
<LuMenu className="h-6 w-6" />
)}
</Button>
);
@@ -1,8 +1,9 @@
'use client';
import { Button, MoonIcon, SunIcon } from '@components/atoms';
import { Button } from '@components/atoms';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { LuMoon, LuSun } from 'react-icons/lu';
export function SimpleThemeToggle() {
const { theme, setTheme } = useTheme();
@@ -28,9 +29,9 @@ export function SimpleThemeToggle() {
className="focus-visible:ring-0 cursor-pointer"
>
{theme === 'dark' ? (
<SunIcon className="h-[1.2rem] w-[1.2rem]" />
<LuSun className="h-[1.2rem] w-[1.2rem]" />
) : (
<MoonIcon className="h-[1.2rem] w-[1.2rem]" />
<LuMoon className="h-[1.2rem] w-[1.2rem]" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
File diff suppressed because one or more lines are too long
+1
View File
@@ -25,6 +25,7 @@
"paths": {
"@components/atoms": ["../../libs/shadcn-ui/src/atoms/index.ts"],
"@utils/ui": ["../../libs/utils/src/index.ts"],
"@schemas": ["../../libs/service/src/schemas/index.ts"],
"@/*": ["src/*"]
}
},
+36 -2
View File
@@ -9,14 +9,16 @@
"version": "0.0.1",
"dependencies": {
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@tailwindcss/vite": "^4.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.503.0",
"react-icons": "^5.5.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4"
"tailwindcss": "^4.1.4",
"zod": "^3.24.4"
},
"devDependencies": {
"tw-animate-css": "^1.2.8"
@@ -504,6 +506,29 @@
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.6.tgz",
"integrity": "sha512-S/hv1mTlgcPX2gCTJrWuTjSXf7ER3Zf7zWGtOprxhIIY93Qin3n5VgNA0Ez9AgrK/lEtlYgzLd4f5x6AVar4Yw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz",
@@ -1792,6 +1817,15 @@
"optional": true
}
}
},
"node_modules/zod": {
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+4 -2
View File
@@ -11,14 +11,16 @@
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@tailwindcss/vite": "^4.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.503.0",
"react-icons": "^5.5.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4"
"tailwindcss": "^4.1.4",
"zod": "^3.24.4"
},
"devDependencies": {
"tw-animate-css": "^1.2.8"
+1 -1
View File
@@ -2,7 +2,7 @@
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from 'react';
import { cn } from './className';
import { cn } from './cn';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
+1 -1
View File
@@ -3,7 +3,7 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from './className';
import { cn } from './cn';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+82
View File
@@ -0,0 +1,82 @@
import * as React from 'react';
import { cn } from './cn';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border bg-card text-card-foreground shadow',
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('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,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+167
View File
@@ -0,0 +1,167 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form';
import { cn } from './cn';
import { Label } from './label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
);
}
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};
-37
View File
@@ -1,37 +0,0 @@
import {
LuArrowDown as ArrowDownIcon,
LuBookOpen as BookOpenIcon,
LuCode as CodeIcon,
LuFacebook as FacebookIcon,
LuInstagram as InstagramIcon,
LuLaptop as LaptopIcon,
LuMenu as MenuIcon,
LuMessageCircle as MessageCircleIcon,
LuMoon as MoonIcon,
LuQuote as QuoteIcon,
LuShare2 as Share2Icon,
LuSparkles as SparklesIcon,
LuSun as SunIcon,
LuUsers as UsersIcon,
LuVideo as VideoIcon,
LuX as XIcon,
} from 'react-icons/lu';
export {
ArrowDownIcon,
BookOpenIcon,
CodeIcon,
FacebookIcon,
InstagramIcon,
LaptopIcon,
MenuIcon,
MessageCircleIcon,
MoonIcon,
QuoteIcon,
Share2Icon,
SparklesIcon,
SunIcon,
UsersIcon,
VideoIcon,
XIcon,
};
+4 -1
View File
@@ -1,3 +1,6 @@
export * from './avatar';
export * from './button';
export * from './icons';
export * from './card';
export * from './form';
export * from './input';
export * from './label';
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from './cn';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors 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-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
+23
View File
@@ -0,0 +1,23 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import * as React from 'react';
import { cn } from './cn';
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 };
+3 -3
View File
@@ -1,5 +1,5 @@
@import 'tailwindcss';
@import "tw-animate-css";
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@@ -123,8 +123,8 @@
.container {
margin-left: auto;
margin-right: auto;
padding-left: 2rem;
padding-right: 2rem;
padding-left: 4rem;
padding-right: 4rem;
width: 100%;
}
@media (min-width: 1536px) {