Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f1e43114 | ||
|
|
84c60dcffa | ||
|
|
f432085eb8 | ||
|
|
a4e543d30e | ||
|
|
69b32f7a46 | ||
|
|
37f926a74d | ||
|
|
88e8649b2a | ||
|
|
031256ec4e | ||
|
|
d96df63660 | ||
|
|
8fb3e9a37f | ||
|
|
2280bd6085 | ||
|
|
d39960f446 | ||
|
|
1c1c66fc0f | ||
|
|
60f0e98476 | ||
|
|
472e02e0b0 | ||
|
|
8d71173897 | ||
|
|
d2292a7fa0 | ||
|
|
b655de92c0 | ||
|
|
761eb0963a | ||
|
|
fc3c275fea | ||
|
|
3527458bc9 | ||
|
|
a7a7abddd2 | ||
|
|
06c71e8dcd | ||
|
|
50b9380add | ||
|
|
9bd326d7ad | ||
|
|
432a2d8d9e | ||
|
|
a9ec7e947f | ||
|
|
a867891001 | ||
|
|
6850313fe3 | ||
|
|
c3c203b555 | ||
|
|
341b4eef98 | ||
|
|
d03deb2d91 | ||
|
|
5f97352031 | ||
|
|
3b71a97d16 | ||
|
|
6e940d749a | ||
|
|
e971d33a05 | ||
|
|
0296a891eb | ||
|
|
47c8a25574 | ||
|
|
671a4f596b | ||
|
|
8c8e2c646f | ||
|
|
035f7fa8cd | ||
|
|
0a4921f2ef | ||
|
|
dbaaddab2e | ||
|
|
6c640f1cd4 | ||
|
|
e6f1b80cd3 | ||
|
|
5ca4caa0ba | ||
|
|
5e70f36a7e | ||
|
|
d009adcd70 | ||
|
|
92842c29bb | ||
|
|
684f299194 | ||
|
|
d5607b456b | ||
|
|
bb9c2f30a8 | ||
|
|
6819c24c6a | ||
|
|
f05fd5331d | ||
|
|
b1d9a56dda |
@@ -1 +1,4 @@
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
TURNSTILE_SECRET_KEY=
|
||||
NEXT_PUBLIC_TURNSTILE_SITEKEY=
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export async function fetchPostverifyTurnstile(
|
||||
token: string,
|
||||
remoteIp?: string
|
||||
): Promise<boolean> {
|
||||
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
const params = new URLSearchParams({
|
||||
secret: String(process.env.TURNSTILE_SECRET_KEY),
|
||||
response: token,
|
||||
});
|
||||
if (remoteIp) {
|
||||
params.append('remoteip', remoteIp);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Turnstile verify HTTP error', res.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as TurnstileVerifyResponse;
|
||||
if (!data.success) {
|
||||
console.warn('Turnstile failure', data['error-codes']);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
interface TurnstileVerifyResponse {
|
||||
success: boolean;
|
||||
challenge_ts: string;
|
||||
hostname: string;
|
||||
'error-codes'?: string[];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use server';
|
||||
|
||||
import { fetcher } from '@/lib/fetcher';
|
||||
import { getRemoteIp } from '@/lib/headers';
|
||||
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||
import {
|
||||
forgotPasswordValidationSchema,
|
||||
ForgotPasswordValidationType,
|
||||
} from '../_validation/forgot-password-validation';
|
||||
|
||||
export async function ForgotPasswordAction(
|
||||
request: ForgotPasswordValidationType
|
||||
) {
|
||||
const validRequest = forgotPasswordValidationSchema.parse(request);
|
||||
const remoteIp = await getRemoteIp();
|
||||
|
||||
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||
validRequest.token,
|
||||
remoteIp
|
||||
);
|
||||
|
||||
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
||||
|
||||
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
|
||||
body: {
|
||||
email: validRequest.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@components';
|
||||
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { useRef, useState } from 'react';
|
||||
import { LuLoader } from 'react-icons/lu';
|
||||
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
|
||||
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
|
||||
import { ForgotPasswordValidationType } from '../_validation/forgot-password-validation';
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const ref = useRef<TurnstileInstance | null>(null);
|
||||
|
||||
const [step, setStep] = useState<number>(1);
|
||||
const [emailValue, setEmailValue] = useState<string>('');
|
||||
|
||||
const form = useFormForgotPassword();
|
||||
const { mutate, isPending, error } = usePostForgotPassowrd(form);
|
||||
|
||||
const handleFirstStep = (values: ForgotPasswordValidationType) => {
|
||||
setEmailValue(values.email);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleSecondStep = () => {
|
||||
mutate({ email: emailValue, token: form.getValues('token') });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={
|
||||
step === 1
|
||||
? form.handleSubmit(handleFirstStep)
|
||||
: (e) => {
|
||||
e.preventDefault();
|
||||
handleSecondStep();
|
||||
}
|
||||
}
|
||||
className="w-full space-y-4"
|
||||
>
|
||||
{error && (
|
||||
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="emailmu@mail.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<Turnstile
|
||||
ref={ref}
|
||||
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||
onSuccess={(token) => form.setValue('token', token)}
|
||||
options={{ theme: 'light', size: 'flexible', language: 'id' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || (step === 2 && !form.watch('token'))}
|
||||
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
||||
>
|
||||
{isPending ? (
|
||||
<LuLoader className="h-5 w-5 animate-spin" />
|
||||
) : step === 1 ? (
|
||||
'Selanjutnya'
|
||||
) : (
|
||||
'Reset password'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
forgotPasswordValidationSchema,
|
||||
type ForgotPasswordValidationType,
|
||||
} from '../_validation/forgot-password-validation';
|
||||
|
||||
export function useFormForgotPassword() {
|
||||
return useForm<ForgotPasswordValidationType>({
|
||||
resolver: zodResolver(forgotPasswordValidationSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
token: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { ForgotPasswordAction } from '../_actions/forgot-password-action';
|
||||
import { useFormForgotPassword } from './use-form-forgot-password';
|
||||
|
||||
export function usePostForgotPassowrd(
|
||||
form: ReturnType<typeof useFormForgotPassword>
|
||||
) {
|
||||
return useMutation({
|
||||
mutationFn: ForgotPasswordAction,
|
||||
onSuccess: ({ message }) => {
|
||||
form.reset();
|
||||
toast.success(message);
|
||||
},
|
||||
onError: ({ message }) => {
|
||||
form.reset();
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const forgotPasswordValidationSchema = z.object({
|
||||
email: z.string().email({ message: 'Format email tidak valid' }),
|
||||
token: z.string(),
|
||||
});
|
||||
export type ForgotPasswordValidationType = z.infer<
|
||||
typeof forgotPasswordValidationSchema
|
||||
>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import { Metadata } from 'next';
|
||||
import { ForgotPasswordForm } from './_components/forgot-password-form';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'IMPHNEN - Signin',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LogoSimple />
|
||||
|
||||
<p className="text-muted-foreground text-center text-sm sm:text-base">
|
||||
Reset password akunmu
|
||||
</p>
|
||||
|
||||
<ForgotPasswordForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { baiJamjureeFont } from '@/lib/fonts';
|
||||
import { Card } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import { ReactNode } from 'react';
|
||||
import { AnimatedBackground } from './_components/animated-background';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
baiJamjureeFont.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,28 @@
|
||||
'use server';
|
||||
|
||||
import { fetcher } from '@/lib/fetcher';
|
||||
import {
|
||||
resetPasswordValidationSchema,
|
||||
ResetPasswordValidationSchema,
|
||||
} from '../_validation/reset-password-validation';
|
||||
|
||||
export async function resetPasswordAction(
|
||||
request: ResetPasswordValidationSchema
|
||||
) {
|
||||
const validRequest = resetPasswordValidationSchema.parse(request);
|
||||
|
||||
if (validRequest.confirm_password !== validRequest.confirm_password) {
|
||||
throw new Error('Password missmatch');
|
||||
}
|
||||
|
||||
const { data, error } = await fetcher.POST('/v1/auth/new-password', {
|
||||
body: {
|
||||
password: validRequest.password,
|
||||
token: validRequest.token,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@components';
|
||||
import { LuLoaderCircle } from 'react-icons/lu';
|
||||
import { usePostResetPassword } from '../_hooks/use-post-reset-password';
|
||||
import { useResetPasswordForm } from '../_hooks/use-reset-password-form';
|
||||
import { ResetPasswordValidationSchema } from '../_validation/reset-password-validation';
|
||||
|
||||
export function ResetPasswordForm() {
|
||||
const form = useResetPasswordForm();
|
||||
const { mutate, error, isPending } = usePostResetPassword(form);
|
||||
|
||||
const onSubmit = (values: ResetPasswordValidationSchema) => {
|
||||
mutate(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
{error && (
|
||||
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm w-full">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 w-full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirm_password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<LuLoaderCircle className="size-5 animate-spin" />
|
||||
) : (
|
||||
'Ubah password'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { resetPasswordAction } from '../_actions/reset-password-actions';
|
||||
import { useResetPasswordForm } from './use-reset-password-form';
|
||||
|
||||
export function usePostResetPassword(
|
||||
form: ReturnType<typeof useResetPasswordForm>
|
||||
) {
|
||||
const router = useRouter();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: resetPasswordAction,
|
||||
onSuccess: ({ message }) => {
|
||||
form.reset();
|
||||
router.replace('/signin');
|
||||
toast.success(message);
|
||||
},
|
||||
onError: ({ message }) => {
|
||||
form.reset();
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
resetPasswordValidationSchema,
|
||||
ResetPasswordValidationSchema,
|
||||
} from '../_validation/reset-password-validation';
|
||||
|
||||
export function useResetPasswordForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const tokenFromQuery = searchParams.get('token') ?? '';
|
||||
|
||||
return useForm<ResetPasswordValidationSchema>({
|
||||
resolver: zodResolver(resetPasswordValidationSchema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
token: tokenFromQuery,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const resetPasswordValidationSchema = z
|
||||
.object({
|
||||
password: z
|
||||
.string({
|
||||
required_error: 'Password tidak boleh kosong',
|
||||
invalid_type_error: 'Password harus berupa string',
|
||||
})
|
||||
.min(8, 'Password harus minimal 8 karakter')
|
||||
.max(50, 'Password tidak boleh lebih dari 50 karakter')
|
||||
.regex(
|
||||
/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).+$/,
|
||||
'Password harus mengandung setidaknya satu huruf kapital, satu huruf kecil, satu angka, dan satu karakter spesial'
|
||||
),
|
||||
confirm_password: z
|
||||
.string({
|
||||
required_error: 'Konfirmasi password tidak boleh kosong',
|
||||
})
|
||||
.min(8, 'Konfirmasi password harus minimal 8 karakter')
|
||||
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
|
||||
token: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirm_password, {
|
||||
message: 'Password dan Konfirmasi Password harus sama',
|
||||
path: ['confirm_password'],
|
||||
});
|
||||
|
||||
export type ResetPasswordValidationSchema = z.infer<
|
||||
typeof resetPasswordValidationSchema
|
||||
>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { use } from 'react';
|
||||
import { ResetPasswordForm } from './_components/reset-password-form';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}) {
|
||||
const { token } = use(searchParams);
|
||||
|
||||
if (!token) redirect('/signin');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LogoSimple />
|
||||
|
||||
<p className="text-muted-foreground text-center text-sm sm:text-base">
|
||||
Ubah passwordmu
|
||||
</p>
|
||||
|
||||
<ResetPasswordForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use server';
|
||||
|
||||
import { setAccessToken, setRefreshToken } from '@/lib/cookies';
|
||||
import { fetchPostSignin } from '../_http/fetch-post-signin';
|
||||
import {
|
||||
type SignInValidationType,
|
||||
signInValidationSchema,
|
||||
} from '../_validation/signin-validation';
|
||||
|
||||
export async function SigninAction(request: SignInValidationType) {
|
||||
const validRequest = signInValidationSchema.parse(request);
|
||||
|
||||
const { data } = await fetchPostSignin(validRequest);
|
||||
|
||||
const accessToken = data.token.access_token;
|
||||
const refreshToken = data.token.refresh_token;
|
||||
|
||||
await setAccessToken(accessToken);
|
||||
await setRefreshToken(refreshToken);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@components';
|
||||
import Link from 'next/link';
|
||||
import { LuLoader } from 'react-icons/lu';
|
||||
import { useFormSignin } from '../_hooks/use-form-signin';
|
||||
import { usePostSignin } from '../_hooks/use-post-signin';
|
||||
import { type SignInValidationType } from '../_validation/signin-validation';
|
||||
|
||||
export function SigninForm() {
|
||||
const form = useFormSignin();
|
||||
const { mutate, isPending, error } = usePostSignin(form);
|
||||
|
||||
const onSubmit = (values: SignInValidationType) => {
|
||||
mutate(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="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 />
|
||||
<div className="text-right mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-primary-500 font-bold hover:underline"
|
||||
>
|
||||
Lupa Password?
|
||||
</Link>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full font-bold">
|
||||
{isPending ? <LuLoader className="h-5 w-5 animate-spin" /> : 'Masuk'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
signInValidationSchema,
|
||||
type SignInValidationType,
|
||||
} from '../_validation/signin-validation';
|
||||
|
||||
export function useFormSignin() {
|
||||
return useForm<SignInValidationType>({
|
||||
resolver: zodResolver(signInValidationSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { SigninAction } from '../_actions/signin-action';
|
||||
import { useFormSignin } from './use-form-signin';
|
||||
|
||||
export function usePostSignin(form: ReturnType<typeof useFormSignin>) {
|
||||
const router = useRouter();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: SigninAction,
|
||||
|
||||
onSuccess: () => {
|
||||
router.push('/');
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
form.resetField('password');
|
||||
|
||||
if (error.message.includes('not active')) {
|
||||
setTimeout(() => {
|
||||
router.push(`/verification?ref=${variables.email}`);
|
||||
}, 750);
|
||||
}
|
||||
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fetcher } from '@/lib/fetcher';
|
||||
import { SignInValidationType } from '../_validation/signin-validation';
|
||||
|
||||
export async function fetchPostSignin({
|
||||
email,
|
||||
password,
|
||||
}: SignInValidationType) {
|
||||
const { data, error, response } = await fetcher.POST('/v1/auth/login', {
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Someting went wrong, please try again later');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const signInValidationSchema = z.object({
|
||||
email: z
|
||||
.string({
|
||||
required_error: 'Email tidak boleh kosong',
|
||||
invalid_type_error: 'Email harus berupa string',
|
||||
})
|
||||
.min(1, 'Email tidak boleh kosong')
|
||||
.email('Email harus valid'),
|
||||
password: z
|
||||
.string({
|
||||
required_error: 'Password tidak boleh kosong',
|
||||
invalid_type_error: 'Password harus berupa string',
|
||||
})
|
||||
.min(1, 'Password tidak boleh kosong'),
|
||||
});
|
||||
export type SignInValidationType = z.infer<typeof signInValidationSchema>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { SigninForm } from './_components/signin-form';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'IMPHNEN - Signin',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
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="font-bold hover:underline hover:underline-offset-4 transition-colors text-primary-500"
|
||||
>
|
||||
Daftar
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use server';
|
||||
|
||||
import { getRemoteIp } from '@/lib/headers';
|
||||
import { z } from 'zod';
|
||||
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
||||
import { signupValidationSchema } from '../_validation/signup-validation';
|
||||
|
||||
export async function SignupAction(
|
||||
request: z.infer<typeof signupValidationSchema>
|
||||
) {
|
||||
const validRequest = signupValidationSchema.parse(request);
|
||||
const remoteIp = await getRemoteIp();
|
||||
|
||||
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||
validRequest.token,
|
||||
remoteIp
|
||||
);
|
||||
|
||||
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
||||
|
||||
const data = await fetchPostSignin(validRequest);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@components';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { LuLoader } from 'react-icons/lu';
|
||||
import { z } from 'zod';
|
||||
import { SignupAction } from '../_actions/signup-action';
|
||||
import {
|
||||
signupValidationSchema,
|
||||
stepOneSignupValidationSchema,
|
||||
stepTwoSignupValidationSchema,
|
||||
} from '../_validation/signup-validation';
|
||||
|
||||
export function SignupForm() {
|
||||
const router = useRouter();
|
||||
const ref = useRef<TurnstileInstance | null>(null);
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [stepOneData, setStepOneData] = useState<z.infer<
|
||||
typeof stepOneSignupValidationSchema
|
||||
> | null>(null);
|
||||
|
||||
const firstForm = useForm<z.infer<typeof stepOneSignupValidationSchema>>({
|
||||
resolver: zodResolver(stepOneSignupValidationSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
phone_number: '',
|
||||
fullname: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const secondForm = useForm<z.infer<typeof stepTwoSignupValidationSchema>>({
|
||||
resolver: zodResolver(stepTwoSignupValidationSchema),
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate, isPending, error } = useMutation({
|
||||
mutationFn: async (data: z.infer<typeof signupValidationSchema>) => {
|
||||
const result = await SignupAction(data);
|
||||
|
||||
return {
|
||||
...result,
|
||||
email: data.email,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ email }) => {
|
||||
router.push(`/verification?ref=${email}`);
|
||||
},
|
||||
onError: () => {
|
||||
ref.current?.reset();
|
||||
secondForm.resetField('token');
|
||||
},
|
||||
});
|
||||
|
||||
const handleFirstSubmit = (
|
||||
values: z.infer<typeof stepOneSignupValidationSchema>
|
||||
) => {
|
||||
setStepOneData(values);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleSecondSubmit = (
|
||||
values: z.infer<typeof stepTwoSignupValidationSchema>
|
||||
) => {
|
||||
if (stepOneData) {
|
||||
mutate({ ...stepOneData, ...values });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{step === 1 && (
|
||||
<Form {...firstForm}>
|
||||
<form
|
||||
onSubmit={firstForm.handleSubmit(handleFirstSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={firstForm.control}
|
||||
name="fullname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Nama Lengkap" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={firstForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="emailmu@mail.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={firstForm.control}
|
||||
name="phone_number"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nomor Telepon</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="08123456789" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={firstForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={firstForm.control}
|
||||
name="confirm_password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#5fbaef] hover:bg-[#22a5f1]"
|
||||
>
|
||||
Selanjutnya
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<Form {...secondForm}>
|
||||
<form
|
||||
onSubmit={secondForm.handleSubmit(handleSecondSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
{error && (
|
||||
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Turnstile
|
||||
ref={ref}
|
||||
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||
onSuccess={(token) => secondForm.setValue('token', token)}
|
||||
options={{
|
||||
theme: 'light',
|
||||
size: 'flexible',
|
||||
language: 'id',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Kembali
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !secondForm.watch('token')}
|
||||
>
|
||||
{isPending ? (
|
||||
<LuLoader className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Daftar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fetcher } from '@/lib/fetcher';
|
||||
import { SignupValidationSchema } from '../_validation/signup-validation';
|
||||
|
||||
export async function fetchPostSignin({
|
||||
email,
|
||||
password,
|
||||
fullname,
|
||||
phone_number,
|
||||
}: SignupValidationSchema) {
|
||||
const { data, error, response } = await fetcher.POST('/v1/auth/register', {
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
fullname,
|
||||
phone_number,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Someting went wrong, please try again later');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const stepOneSignupValidationSchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string({
|
||||
required_error: 'Email tidak boleh kosong',
|
||||
invalid_type_error: 'Email harus berupa string',
|
||||
})
|
||||
.min(1, 'Email tidak boleh kosong')
|
||||
.email('Email harus valid'),
|
||||
fullname: z
|
||||
.string({
|
||||
required_error: 'Nama tidak boleh kosong',
|
||||
invalid_type_error: 'Nama harus berupa string',
|
||||
})
|
||||
.min(1, 'Nama tidak boleh kosong')
|
||||
.max(50, 'Nama tidak boleh lebih dari 50 karakter'),
|
||||
password: z
|
||||
.string({
|
||||
required_error: 'Password tidak boleh kosong',
|
||||
invalid_type_error: 'Password harus berupa string',
|
||||
})
|
||||
.min(8, 'Password harus minimal 8 karakter')
|
||||
.max(50, 'Password tidak boleh lebih dari 50 karakter')
|
||||
.regex(
|
||||
/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).+$/,
|
||||
'Password harus mengandung setidaknya satu huruf kapital, satu huruf kecil, satu angka, dan satu karakter spesial'
|
||||
),
|
||||
confirm_password: z
|
||||
.string({
|
||||
required_error: 'Konfirmasi password tidak boleh kosong',
|
||||
})
|
||||
.min(8, 'Konfirmasi password harus minimal 8 karakter')
|
||||
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
|
||||
phone_number: z
|
||||
.string({
|
||||
required_error: 'Nomor telepon tidak boleh kosong',
|
||||
invalid_type_error: 'Nomor telepon harus berupa string',
|
||||
})
|
||||
.min(10, 'Nomor telepon tidak boleh kurang dari 10 karakter')
|
||||
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter')
|
||||
.regex(/^\d+$/, 'Nomor telepon hanya boleh berisi angka'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirm_password, {
|
||||
message: 'Password dan Konfirmasi Password harus sama',
|
||||
path: ['confirm_password'],
|
||||
});
|
||||
|
||||
export const stepTwoSignupValidationSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const signupValidationSchema = stepOneSignupValidationSchema.and(
|
||||
stepTwoSignupValidationSchema
|
||||
);
|
||||
export type SignupValidationSchema = z.infer<typeof signupValidationSchema>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { SignupForm } from './_components/signup-form';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'IMPHNEN - Signup',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
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>
|
||||
|
||||
<SignupForm />
|
||||
|
||||
<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">
|
||||
Sudah punya akun?{' '}
|
||||
<Link
|
||||
href="/signin"
|
||||
className="font-bold hover:underline hover:underline-offset-4 transition-colors text-primary-500"
|
||||
>
|
||||
Masuk
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use server';
|
||||
|
||||
import { fetcher } from '@/lib/fetcher';
|
||||
import { getRemoteIp } from '@/lib/headers';
|
||||
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||
import {
|
||||
resendOTPValidationSchema,
|
||||
type ResendOTPValidationType,
|
||||
} from '../_validation/resend-otp-validation';
|
||||
|
||||
export async function resendOTPAction(request: ResendOTPValidationType) {
|
||||
const validRequest = resendOTPValidationSchema.parse(request);
|
||||
const remoteIp = await getRemoteIp();
|
||||
|
||||
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||
validRequest.token,
|
||||
remoteIp
|
||||
);
|
||||
|
||||
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
||||
|
||||
const { data, error } = await fetcher.POST('/v1/auth/send-otp', {
|
||||
body: {
|
||||
email: validRequest.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use server';
|
||||
|
||||
import { fetchPostVerifyEmail } from '../_http/fetch-post-verify-email';
|
||||
import {
|
||||
type VerifyEmailValidationType,
|
||||
verifyEmailValidationSchema,
|
||||
} from '../_validation/verify-email-validation';
|
||||
|
||||
export async function verifyEmailAction(request: VerifyEmailValidationType) {
|
||||
const validRequest = verifyEmailValidationSchema.parse(request);
|
||||
|
||||
const data = await fetchPostVerifyEmail(validRequest);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@components';
|
||||
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { useRef } from 'react';
|
||||
import { useFormResendOTP } from '../_hooks/use-form-resend-otp';
|
||||
import { usePostResendOTP } from '../_hooks/use-post-resend-otp';
|
||||
import { ResendOTPValidationType } from '../_validation/resend-otp-validation';
|
||||
|
||||
export function ResendOTPForm() {
|
||||
const ref = useRef<TurnstileInstance | null>(null);
|
||||
|
||||
const form = useFormResendOTP();
|
||||
|
||||
const { mutate, isPending, error } = usePostResendOTP(form);
|
||||
|
||||
const onSubmit = (values: ResendOTPValidationType) => {
|
||||
mutate(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Turnstile
|
||||
ref={ref}
|
||||
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||
onSuccess={(token) => form.setValue('token', token)}
|
||||
options={{
|
||||
theme: 'light',
|
||||
size: 'flexible',
|
||||
language: 'id',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending || !form.watch('token')}
|
||||
>
|
||||
Kirim Ulang Kode OTP
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@utils';
|
||||
import { useState } from 'react';
|
||||
import { ResendOTPForm } from './resend-otp-form';
|
||||
import { VerifyEmailForm } from './verify-email-form';
|
||||
|
||||
export function VerificationTabs() {
|
||||
const [activeTab, setActiveTab] = useState<'form' | 'resend'>('form');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex space-x-2 border-b">
|
||||
<button
|
||||
className={cn(
|
||||
'py-2 px-4 w-full',
|
||||
activeTab === 'form'
|
||||
? 'border-b-2 border-primary-500 font-semibold'
|
||||
: 'text-gray-500'
|
||||
)}
|
||||
onClick={() => setActiveTab('form')}
|
||||
>
|
||||
Verifikasi Email
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'py-2 px-4 w-full',
|
||||
activeTab === 'resend'
|
||||
? 'border-b-2 border-primary-500 font-semibold'
|
||||
: 'text-gray-500'
|
||||
)}
|
||||
onClick={() => setActiveTab('resend')}
|
||||
>
|
||||
Resend OTP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'form' ? <VerifyEmailForm /> : <ResendOTPForm />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@components';
|
||||
import { LuLoader } from 'react-icons/lu';
|
||||
import { useFormVerifyEmail } from '../_hooks/use-form-verify-email';
|
||||
import { usePostVerifyEmail } from '../_hooks/use-post-verify-email';
|
||||
import { type VerifyEmailValidationType } from '../_validation/verify-email-validation';
|
||||
|
||||
export function VerifyEmailForm() {
|
||||
const form = useFormVerifyEmail();
|
||||
const { mutate, isPending, error } = usePostVerifyEmail(form);
|
||||
|
||||
const onSubmit = (values: VerifyEmailValidationType) => {
|
||||
mutate(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="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="otp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>OTP</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="123456" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
||||
>
|
||||
{isPending ? (
|
||||
<LuLoader className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Verifikasi'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
resendOTPValidationSchema,
|
||||
type ResendOTPValidationType,
|
||||
} from '../_validation/resend-otp-validation';
|
||||
|
||||
export function useFormResendOTP() {
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get('ref');
|
||||
|
||||
return useForm<ResendOTPValidationType>({
|
||||
resolver: zodResolver(resendOTPValidationSchema),
|
||||
defaultValues: {
|
||||
email: email ?? '',
|
||||
token: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
verifyEmailValidationSchema,
|
||||
type VerifyEmailValidationType,
|
||||
} from '../_validation/verify-email-validation';
|
||||
|
||||
export function useFormVerifyEmail() {
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get('ref');
|
||||
|
||||
return useForm<VerifyEmailValidationType>({
|
||||
resolver: zodResolver(verifyEmailValidationSchema),
|
||||
defaultValues: {
|
||||
email: email ?? '',
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { resendOTPAction } from '../_actions/resend-otp-action';
|
||||
import { useFormResendOTP } from './use-form-resend-otp';
|
||||
|
||||
export function usePostResendOTP(form: ReturnType<typeof useFormResendOTP>) {
|
||||
return useMutation({
|
||||
mutationFn: resendOTPAction,
|
||||
onSuccess: ({ message }) => {
|
||||
form.reset();
|
||||
toast.success(message);
|
||||
},
|
||||
onError: ({ message }) => {
|
||||
form.reset();
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { verifyEmailAction } from '../_actions/verify-email-action';
|
||||
import { useFormVerifyEmail } from './use-form-verify-email';
|
||||
|
||||
export function usePostVerifyEmail(
|
||||
form: ReturnType<typeof useFormVerifyEmail>
|
||||
) {
|
||||
const router = useRouter();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: verifyEmailAction,
|
||||
onSuccess: () => {
|
||||
router.push('/');
|
||||
},
|
||||
onError: () => {
|
||||
form.resetField('otp');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fetcher } from '@/lib/fetcher';
|
||||
import { VerifyEmailValidationType } from '../_validation/verify-email-validation';
|
||||
|
||||
export async function fetchPostVerifyEmail({
|
||||
email,
|
||||
otp,
|
||||
}: VerifyEmailValidationType) {
|
||||
const formattedOTP = parseInt(otp);
|
||||
|
||||
const { data, error, response } = await fetcher.POST(
|
||||
'/v1/auth/verify-email',
|
||||
{
|
||||
body: {
|
||||
email,
|
||||
otp: formattedOTP,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Something went wrong, please try again later');
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const resendOTPValidationSchema = z.object({
|
||||
email: z.string().email({ message: 'Format email tidak valid' }),
|
||||
token: z.string().min(1, { message: 'OTP harus terdiri dari 1 digit' }),
|
||||
});
|
||||
export type ResendOTPValidationType = z.infer<typeof resendOTPValidationSchema>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const verifyEmailValidationSchema = z.object({
|
||||
email: z.string().email({ message: 'Format email tidak valid' }),
|
||||
otp: z
|
||||
.string()
|
||||
.min(6, { message: 'OTP harus terdiri dari 6 digit' })
|
||||
.max(6, { message: 'OTP harus terdiri dari 6 digit' }),
|
||||
});
|
||||
export type VerifyEmailValidationType = z.infer<
|
||||
typeof verifyEmailValidationSchema
|
||||
>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Suspense } from 'react';
|
||||
import { LogoSimple } from '../../_components/logo';
|
||||
import { VerificationTabs } from './_components/verification-tabs';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LogoSimple />
|
||||
|
||||
<p className="text-muted-foreground text-center text-sm sm:text-base">
|
||||
Verifikasi akun mu sekarang
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Suspense>
|
||||
<VerificationTabs />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@components';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function CallToAction() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
return (
|
||||
<section className="w-full py-20 md:py-32 relative overflow-hidden">
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background to-primary/20" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.2),transparent_70%)]" />
|
||||
</div>
|
||||
|
||||
<div className="container px-4 md:px-6" ref={ref}>
|
||||
<motion.div
|
||||
className="max-w-4xl mx-auto rounded-2xl overflow-hidden border shadow-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="relative p-8 md:p-12 lg:p-16 bg-background">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5" />
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-6">
|
||||
Siap Menjadi{' '}
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Programmer Handal?
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto">
|
||||
Bergabunglah dengan komunitas IMPHNEN sekarang dan mulai
|
||||
perjalanan programming mu dengan cara yang menyenangkan!
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
|
||||
onClick={() =>
|
||||
window.open('https://discord.gg/imphnen', '_blank')
|
||||
}
|
||||
>
|
||||
Gabung Discord
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-primary hover:bg-primary/10"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'https://facebook.com/groups/programmerhandal',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
>
|
||||
Join Facebook Group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute -top-12 -left-12 w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
|
||||
<div className="absolute -bottom-12 -right-12 w-24 h-24 rounded-full bg-blue-400/10 blur-2xl" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import COMMUNITIES_STATS from '@/data/communities-stats.json';
|
||||
import COMMUNITIES from '@/data/communities.json';
|
||||
import { Button } from '@components';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function Community() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="komunitas"
|
||||
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.1),transparent_70%)]" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(59,130,246,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(59,130,246,0.01)_1px,transparent_1px)] bg-[size:14px_14px]" />
|
||||
</div>
|
||||
<div className="container px-4 md:px-6" ref={ref}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl text-center mb-4">
|
||||
Komunitas{' '}
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Kami
|
||||
</span>
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground text-center md:text-lg">
|
||||
Bergabunglah dengan ribuan programmer Indonesia yang saling membantu
|
||||
dan berbagi pengalaman.
|
||||
</p>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="grid gap-8 md:grid-cols-3 mt-12"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
{COMMUNITIES.map((c, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-xl"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="relative z-10">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<Icon
|
||||
icon={c.iconName}
|
||||
className="h-6 w-6 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold">{c.title}</h3>
|
||||
<p className="mb-6 text-muted-foreground">{c.description}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300"
|
||||
onClick={() => window.open(c.buttonLink, '_blank')}
|
||||
>
|
||||
{c.buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-20 h-20 bg-gradient-to-tl from-primary/20 to-transparent rounded-tl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
{COMMUNITIES_STATS.map((s, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import FEATURES from '@/data/features.json';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function Features() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="fitur"
|
||||
className="w-full py-20 md:py-32 relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-muted/50" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-gradient-to-tr from-primary/5 to-blue-400/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="container px-4 md:px-6" ref={ref}>
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||
<motion.div
|
||||
className="space-y-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary">
|
||||
Fitur Unggulan
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
||||
Belajar programming dengan cara yang lebih baik
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
||||
IMPHNEN hadir dengan berbagai fitur untuk membantu kamu menjadi
|
||||
programmer handal tanpa harus pusing dengan coding.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="grid gap-8 md:grid-cols-2 lg:grid-cols-4"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
{FEATURES.map((feature, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="group relative overflow-hidden rounded-xl border bg-background/50 backdrop-blur-sm p-6 transition-all hover:shadow-md hover:shadow-primary/5 hover:border-primary/50"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 group-hover:bg-primary/20 transition-colors">
|
||||
<Icon
|
||||
icon={feature.iconName}
|
||||
className="h-6 w-6 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold">{feature.title}</h3>
|
||||
<p className="text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 h-1 w-0 bg-gradient-to-r from-primary to-blue-400 group-hover:w-full transition-all duration-300" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import LEARNING_RESOURCES from '@/data/learning-resources.json';
|
||||
import { Button } from '@components';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function LearningResources() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="sumber-belajar"
|
||||
className="w-full py-20 md:py-32 relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-background" />
|
||||
<div className="absolute top-0 right-0 w-1/2 h-1/2 bg-gradient-to-bl from-primary/5 to-transparent blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-1/2 h-1/2 bg-gradient-to-tr from-blue-400/5 to-transparent blur-3xl" />
|
||||
</div>
|
||||
<div className="container px-4 md:px-6" ref={ref}>
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
||||
Sumber Belajar
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
||||
Akses berbagai materi belajar yang akan membantu kamu menguasai
|
||||
konsep programming dengan cara yang menyenangkan.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
<motion.div
|
||||
className="grid gap-8 md:grid-cols-2 lg:grid-cols-4"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
{LEARNING_RESOURCES.map((r, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 hover:shadow-lg"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<div className="absolute top-0 left-0 h-1 w-full bg-gradient-to-r from-primary/50 to-blue-400/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative z-10">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<Icon
|
||||
icon={r.icon}
|
||||
className="h-6 w-6 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold">{r.title}</h3>
|
||||
<p className="mb-6 text-muted-foreground">{r.description}</p>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-medium text-primary hover:text-primary/80"
|
||||
onClick={() => window.open(r.buttonLink, '_blank')}
|
||||
>
|
||||
{r.buttonText}
|
||||
<Icon icon="tabler:arrow-right" className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute -bottom-32 -right-32 w-64 h-64 bg-gradient-to-tl from-primary/10 to-transparent rounded-full opacity-0 group-hover:opacity-100 transition-all duration-500 group-hover:-translate-y-10 group-hover:-translate-x-10" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-0">
|
||||
<div className="p-8 md:p-12 flex flex-col justify-center">
|
||||
<div className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary mb-4 w-fit">
|
||||
Rekomendasi Terbaik
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">
|
||||
Kursus Lengkap Web Development
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Pelajari HTML, CSS, JavaScript, React, dan Node.js dalam satu
|
||||
kursus komprehensif yang dirancang untuk pemula hingga tingkat
|
||||
menengah.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button
|
||||
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
|
||||
onClick={() => window.open('/', '_blank')}
|
||||
>
|
||||
Mulai Kursus
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('/', '_blank')}
|
||||
>
|
||||
Lihat Silabus
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-64 md:h-auto">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-blue-400/20" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-background/80 backdrop-blur-sm flex items-center justify-center cursor-pointer hover:bg-background transition-colors">
|
||||
<div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-primary border-b-8 border-b-transparent ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import TESTIMONIAL_STATS from '@/data/testimonial-stats.json';
|
||||
import TESTIMONIALS from '@/data/testimonials.json';
|
||||
import { QuoteIcon } from '@components';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function Testimonials() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: { y: 0, opacity: 1, transition: { duration: 0.5 } },
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="testimoni"
|
||||
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
|
||||
ref={ref}
|
||||
>
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.1),transparent_50%)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,rgba(96,165,250,0.1),transparent_50%)]" />
|
||||
</div>
|
||||
|
||||
<div className="container px-4 md:px-6">
|
||||
<motion.div
|
||||
className="flex flex-col items-center justify-center space-y-4 text-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl lg:text-5xl">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Testimoni
|
||||
</span>
|
||||
<span> Member</span>
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
||||
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN?
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="grid gap-8 md:grid-cols-3"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
{TESTIMONIALS.map((t, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors">
|
||||
<QuoteIcon className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<p className="mb-6 text-muted-foreground italic">
|
||||
“{t.quote}”
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20">
|
||||
<Image
|
||||
src={t.avatar}
|
||||
alt={t.name}
|
||||
width={600}
|
||||
height={500}
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold">{t.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -left-1 w-20 h-20 bg-gradient-to-tr from-primary/10 to-transparent rounded-tr-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-8 items-center">
|
||||
<div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">
|
||||
Bergabunglah dengan 10,000+ programmer Indonesia lainnya
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Komunitas kami terus berkembang dengan programmer dari berbagai
|
||||
latar belakang dan tingkat keahlian. Bersama-sama, kita belajar,
|
||||
berbagi, dan tumbuh sebagai profesional.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{TESTIMONIAL_STATS.map((s, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-4 text-center">
|
||||
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { CallToAction } from './_components/call-to-action';
|
||||
import { Community } from './_components/community';
|
||||
import { Features } from './_components/features';
|
||||
import { Hero } from './_components/hero';
|
||||
import { LearningResources } from './_components/learning-resources';
|
||||
import { Testimonials } from './_components/testimonials';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<Features />
|
||||
<Community />
|
||||
<LearningResources />
|
||||
<Testimonials />
|
||||
<CallToAction />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import SOCIALS from '@/data/socials.json';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
FaArrowRight,
|
||||
FaDiscord,
|
||||
FaFacebook,
|
||||
FaInstagram,
|
||||
FaLinkedin,
|
||||
FaTiktok,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
export function CommunitySection() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const getIconComponent = (iconName: string) => {
|
||||
switch (iconName) {
|
||||
case 'FaFacebook':
|
||||
return FaFacebook;
|
||||
case 'FaDiscord':
|
||||
return FaDiscord;
|
||||
case 'FaInstagram':
|
||||
return FaInstagram;
|
||||
case 'FaTiktok':
|
||||
return FaTiktok;
|
||||
case 'FaLinkedin':
|
||||
return FaLinkedin;
|
||||
default:
|
||||
return FaArrowRight;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformColors = (iconName: string) => {
|
||||
switch (iconName) {
|
||||
case 'FaFacebook':
|
||||
return {
|
||||
iconColor: 'text-[#1877F2]',
|
||||
};
|
||||
case 'FaDiscord':
|
||||
return {
|
||||
iconColor: 'text-[#5865F2]',
|
||||
};
|
||||
case 'FaInstagram':
|
||||
return {
|
||||
iconColor: 'text-[#E4405F]',
|
||||
};
|
||||
case 'FaTiktok':
|
||||
return {
|
||||
iconColor: 'text-[#000000]',
|
||||
};
|
||||
case 'FaLinkedin':
|
||||
return {
|
||||
iconColor: 'text-[#0A66C2]',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
iconColor: 'text-primary-500',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="community" className="w-full py-20 md:py-28">
|
||||
<div className="container" ref={ref}>
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
|
||||
Bergabung dengan Komunitas Kami di
|
||||
<span className="block mt-2 text-primary-500">
|
||||
Berbagai Platform
|
||||
</span>
|
||||
</h2>
|
||||
<p className="max-w-[600px] mx-auto text-gray-600 md:text-lg/relaxed mt-4">
|
||||
Terhubung dengan sesama developer di komunitas kami
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
{SOCIALS.map((community, index) => {
|
||||
const IconComponent = getIconComponent(community.icon);
|
||||
const colors = getPlatformColors(community.icon);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
className="group relative p-8 bg-white border border-gray-200 rounded-xl hover:border-gray-300 transition-all duration-300"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<IconComponent
|
||||
className={`w-8 h-8 ${colors.iconColor} transition-colors`}
|
||||
/>
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{community.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm/relaxed">
|
||||
{community.description}
|
||||
</p>
|
||||
<a
|
||||
href={community.link}
|
||||
className={`inline-flex items-center gap-2 mt-2 text-sm font-medium transition-colors`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>Jelajahi Komunitas</span>
|
||||
<FaArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import { FaArrowRight } from 'react-icons/fa';
|
||||
|
||||
export function CTASection() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
return (
|
||||
<section className="w-full py-20 md:py-32 bg-gradient-to-br from-primary-500 to-primary-600 relative overflow-hidden">
|
||||
{/* Background pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='52' height='26' viewBox='0 0 52 26' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4v2c-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}}
|
||||
></div>
|
||||
|
||||
<div className="container relative" ref={ref}>
|
||||
<div className="flex flex-col lg:flex-row items-center justify-between gap-12">
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 text-center lg:text-left space-y-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-white leading-tight">
|
||||
<span className="block">LET'S GO</span>
|
||||
<span className="block text-5xl md:text-6xl mt-2">
|
||||
<span className="text-yellow-300">SAAT</span>
|
||||
<span className="text-white">NYA</span>
|
||||
</span>
|
||||
<span className="block mt-2">KAMU JOIN!</span>
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isInView ? { opacity: 1 } : {}}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-lg md:text-xl text-white/90 max-w-2xl mx-auto lg:mx-0"
|
||||
>
|
||||
Jadilah bagian dari komunitas developer terbesar di Indonesia.
|
||||
Tingkatkan skill, perluas jaringan, dan raih kesempatan karir
|
||||
bersama kami!
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ type: 'spring', stiffness: 100 }}
|
||||
className="flex justify-center lg:justify-start"
|
||||
>
|
||||
<a
|
||||
href="#community"
|
||||
className="flex items-center gap-4 px-8 py-4 bg-yellow-300 hover:bg-yellow-400 text-gray-900 rounded-full text-lg font-semibold transition-all hover:gap-6 group"
|
||||
>
|
||||
<span>Join Sekarang</span>
|
||||
<FaArrowRight className="w-5 h-5 transition-all group-hover:rotate-45" />
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Illustration */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex-1 max-w-xl"
|
||||
>
|
||||
<div className="relative p-8">
|
||||
<div className="absolute inset-0 bg-white/10 rounded-3xl transform rotate-6"></div>
|
||||
<div className="relative bg-white/5 rounded-3xl p-8 backdrop-blur-lg border border-white/10">
|
||||
<div className="flex flex-col items-center gap-6 text-white">
|
||||
<LogoSimple className="w-[240px]" />
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-2xl font-bold">180.000+</h3>
|
||||
<p className="text-lg">Programmer Sudah Bergabung</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+23
-30
@@ -1,12 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import HERO_CONTENT from '@/data/hero-content.json';
|
||||
import HERO_STATS from '@/data/hero-stats.json';
|
||||
import { Button, SparklesIcon } from '@components';
|
||||
import { Button } from '@components';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
|
||||
export function Hero() {
|
||||
export function HeroSection() {
|
||||
const router = useRouter();
|
||||
const { communityLabel, headingLine1, headingLine2, description, buttons } =
|
||||
HERO_CONTENT;
|
||||
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,6 +26,7 @@ export function Hero() {
|
||||
|
||||
return (
|
||||
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
|
||||
{/* Background gradients and motion */}
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
|
||||
<div
|
||||
@@ -47,48 +54,34 @@ export function Hero() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
|
||||
<SparklesIcon className="mr-1 h-3.5 w-3.5 text-primary" />
|
||||
<span>Komunitas Programmer Indonesia</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
|
||||
{communityLabel}
|
||||
</span>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
||||
Programmer Handal, <br />
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Tanpa Ribet
|
||||
{headingLine1}{' '}
|
||||
<span className="bg-clip-text bg-gradient-to-r text-primary-500">
|
||||
{headingLine2}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="max-w-[600px] text-muted-foreground md:text-xl">
|
||||
Temukan potensi programming Anda bersama komunitas yang
|
||||
mendukung, tutorial interaktif, dan sumber daya berkualitas
|
||||
tinggi.
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'https://facebook.com/groups/programmerhandal',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
>
|
||||
Mulai Belajar
|
||||
<Button size="lg" onClick={() => router.push(buttons.joinUrl)}>
|
||||
{buttons.join}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto group relative overflow-hidden border-primary"
|
||||
onClick={() =>
|
||||
window.open('https://discord.com/invite/imphnen', '_blank')
|
||||
}
|
||||
variant="bordered"
|
||||
className="w-full sm:w-auto group relative overflow-hidden"
|
||||
onClick={() => router.push(buttons.exploreUrl)}
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-primary/10 to-blue-400/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300" />
|
||||
<span className="relative">Gabung Discord</span>
|
||||
<span className="relative">{buttons.explore}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +89,7 @@ export function Hero() {
|
||||
{HERO_STATS.map(({ value, label }, i) => (
|
||||
<Fragment key={i}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
<div className="text-2xl font-bold bg-clip-text text-primary-500">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import TESTIMONIALS from '@/data/testimonials.json';
|
||||
import { buttonVariants } from '@components';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRef } from 'react';
|
||||
import { FaQuoteLeft } from 'react-icons/fa';
|
||||
|
||||
export function TestimonialSection() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.1 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full py-20 md:py-28 bg-gray-50">
|
||||
<div className="container" ref={ref}>
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
|
||||
Apa Kata Mereka Tentang
|
||||
<span className="block mt-2 text-primary-500">
|
||||
Komunitas Kami?
|
||||
</span>
|
||||
</h2>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mb-16"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
{TESTIMONIALS.map((testimonial) => (
|
||||
<motion.div
|
||||
key={testimonial.id}
|
||||
variants={itemVariants}
|
||||
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Image
|
||||
src={testimonial.image}
|
||||
alt={testimonial.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">
|
||||
{testimonial.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">{testimonial.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-600 relative">
|
||||
<FaQuoteLeft className="text-primary-500/30 w-6 h-6 mb-2" />
|
||||
<p className="text-sm/relaxed">{testimonial.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Link href="/testimonials" className={buttonVariants({ size: 'lg' })}>
|
||||
Tulis Testimonimu
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CommunitySection } from './_components/community-section';
|
||||
import { CTASection } from './_components/cta-section';
|
||||
import { HeroSection } from './_components/hero-section';
|
||||
import { TestimonialSection } from './_components/testimonial-section';
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<CommunitySection />
|
||||
<TestimonialSection />
|
||||
<CTASection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import NAVIGATIONS from '@/data/navigations.json';
|
||||
import SOCIALS from '@/data/socials.json';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
FaDiscord,
|
||||
FaFacebook,
|
||||
FaInstagram,
|
||||
FaLinkedinIn,
|
||||
FaTiktok,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full border-t bg-background py-12 md:py-16">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogoSimple />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ingin Menjadi Programmer Handal Namun Enggan Ngoding
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
href="#"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FaFacebook className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FaDiscord className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FaInstagram className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FaTiktok className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FaLinkedinIn className="size-6" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Halaman</h3>
|
||||
<ul className="space-y-2">
|
||||
{NAVIGATIONS.map(({ link, title }) => (
|
||||
<li key={link}>
|
||||
<Link
|
||||
href={link}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Link</h3>
|
||||
<ul className="space-y-2">
|
||||
{SOCIALS.map(({ link, name }) => (
|
||||
<li key={name}>
|
||||
<Link
|
||||
href={link}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Patners</h3>
|
||||
<ul className="space-y-2"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t pt-8 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
|
||||
Handal, Namun Enggan Ngoding. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import NAVIGATIONS from '@/data/navigations.json';
|
||||
import { Button } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LuMenu, LuX } from 'react-icons/lu';
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 10);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-50 w-full transition-all duration-300',
|
||||
isScrolled
|
||||
? 'bg-background/80 backdrop-blur-md border-b shadow-sm'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative overflow-hidden rounded">
|
||||
<Link href="/">
|
||||
<LogoSimple className="w-[80px]" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
{NAVIGATIONS.map(({ link, title }) => (
|
||||
<Link
|
||||
key={link}
|
||||
href={link}
|
||||
className="text-sm font-medium relative group"
|
||||
>
|
||||
<span className="transition-colors hover:text-primary">
|
||||
{title}
|
||||
</span>
|
||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary-500 transition-all duration-300 group-hover:w-full"></span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
onClick={() => router.push('/signin')}
|
||||
className="hidden md:flex"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/signup')}
|
||||
className="hidden md:flex"
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="flex md:hidden"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<LuX className="size-5" />
|
||||
) : (
|
||||
<LuMenu className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t bg-background/95 backdrop-blur-md">
|
||||
<nav className="container flex flex-col py-4 text-center">
|
||||
{NAVIGATIONS.map(({ link, title }) => (
|
||||
<Link
|
||||
key={link}
|
||||
href={link}
|
||||
className="py-3 text-sm font-medium border-b border-border/50"
|
||||
onClick={() => router.push(link)}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Button onClick={() => router.push('/signin')}>Login</Button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { Button, MoonIcon, SunIcon } from '@components';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function SimpleThemeToggle() {
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function SimpleThemeToggle() {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="bordered"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="focus-visible:ring-0 cursor-pointer"
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import events from '@/data/events.json';
|
||||
import { buttonVariants } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import Image from 'next/image';
|
||||
import { HiCalendar, HiClock, HiLocationMarker } from 'react-icons/hi';
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Jakarta',
|
||||
});
|
||||
};
|
||||
|
||||
const getEventStatus = (endDate: string) => {
|
||||
const now = new Date();
|
||||
const end = new Date(endDate);
|
||||
return end > now ? 'upcoming' : 'past';
|
||||
};
|
||||
|
||||
export default function EventsPage() {
|
||||
const sortedEvents = [...events].sort(
|
||||
(a, b) =>
|
||||
new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{/* Featured Card */}
|
||||
<div className="col-span-full">
|
||||
<div className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden bg-card">
|
||||
<div className="grid md:grid-cols-2">
|
||||
<div className="min-h-96 bg-muted relative">
|
||||
<Image
|
||||
src={sortedEvents[0].thumbnail}
|
||||
alt={sortedEvents[0].name}
|
||||
fill
|
||||
className="object-cover object-top"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="p-8 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="bg-primary/20 text-primary text-xs px-2.5 py-1 rounded-full">
|
||||
Event Terbaru
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
getEventStatus(sortedEvents[0].end_date) === 'upcoming'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{getEventStatus(sortedEvents[0].end_date) === 'upcoming'
|
||||
? 'Upcoming'
|
||||
: 'Selesai'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-4 text-foreground">
|
||||
{sortedEvents[0].name}
|
||||
</h2>
|
||||
<div className="space-y-3 mb-6 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<HiCalendar className="w-4 h-4" />
|
||||
<span>{formatDate(sortedEvents[0].start_date)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HiClock className="w-4 h-4" />
|
||||
<span>
|
||||
{formatTime(sortedEvents[0].start_date)} -{' '}
|
||||
{formatTime(sortedEvents[0].end_date)} WIB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HiLocationMarker className="w-4 h-4" />
|
||||
<span>
|
||||
{sortedEvents[0].type === 'online'
|
||||
? 'Online'
|
||||
: sortedEvents[0].location}
|
||||
</span>
|
||||
</div>
|
||||
{sortedEvents[0].price > 0 && (
|
||||
<div className="mt-1 font-medium">
|
||||
Rp {sortedEvents[0].price.toLocaleString('id-ID')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 line-clamp-4">
|
||||
{sortedEvents[0].description}
|
||||
</p>
|
||||
<a
|
||||
href={sortedEvents[0].detail_link}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'bordered' }),
|
||||
'mt-auto w-full md:w-fit'
|
||||
)}
|
||||
>
|
||||
Lihat Detail
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regular Cards */}
|
||||
{sortedEvents.slice(1).map((event) => (
|
||||
<div
|
||||
key={event.name}
|
||||
className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 bg-card"
|
||||
>
|
||||
<div className="h-48 bg-muted relative">
|
||||
<Image
|
||||
src={event.thumbnail}
|
||||
alt={event.name}
|
||||
fill
|
||||
className="object-cover object-top rounded-t-xl"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
getEventStatus(event.end_date) === 'upcoming'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{getEventStatus(event.end_date) === 'upcoming'
|
||||
? 'Upcoming'
|
||||
: 'Selesai'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-3 text-foreground">
|
||||
{event.name}
|
||||
</h3>
|
||||
<div className="space-y-2 mb-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<HiCalendar className="w-4 h-4" />
|
||||
<span>{formatDate(event.start_date)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HiLocationMarker className="w-4 h-4" />
|
||||
<span>
|
||||
{event.type === 'online' ? 'Online' : event.location}
|
||||
</span>
|
||||
</div>
|
||||
{event.price > 0 && (
|
||||
<div className="font-medium">
|
||||
Rp {event.price.toLocaleString('id-ID')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{event.description}
|
||||
</p>
|
||||
<a
|
||||
href={event.detail_link}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'bordered' }),
|
||||
'w-full text-sm'
|
||||
)}
|
||||
>
|
||||
Lihat Detail
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Footer from './_components/footer';
|
||||
import { Header } from './_components/header';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import TESTIMONIALS from '@/data/testimonials.json';
|
||||
import { Button } from '@components';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { BsChatLeftQuote } from 'react-icons/bs';
|
||||
import { FaQuoteLeft } from 'react-icons/fa';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="py-16 px-4 text-center border-b border-border">
|
||||
<BsChatLeftQuote className="size-14 mx-auto my-4 text-foreground" />
|
||||
<h1 className="text-4xl font-bold mb-4 text-foreground">Testimonial</h1>
|
||||
<p className="max-w-2xl mx-auto text-lg mb-8 text-balance text-muted-foreground">
|
||||
Menampilkan pengalaman dan cerita para member yang telah join ke dalam
|
||||
komunitas kami
|
||||
</p>
|
||||
<Link href="/testimonials/submit">
|
||||
<Button>Tulis Testimonialmu</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 my-16 container">
|
||||
{TESTIMONIALS.map((testimonial) => (
|
||||
<div
|
||||
key={testimonial.id}
|
||||
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Image
|
||||
src={testimonial.image}
|
||||
alt={testimonial.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">
|
||||
{testimonial.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">{testimonial.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-600 relative">
|
||||
<FaQuoteLeft className="text-primary-500/30 w-6 h-6 mb-2" />
|
||||
<p className="text-sm/relaxed">{testimonial.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full border-t bg-background py-12 md:py-16">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
IMPHNEN
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ingin Menjadi Programmer Handal merupakan komunitas Programmer
|
||||
Indonesia.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
href="#"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 8h-3v4h3v12h5v-12h3.642l.358-4h-4v-1.667c0-.955.192-1.333 1.115-1.333h2.885v-5h-3.808c-3.596 0-5.192 1.583-5.192 4.615v3.385z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Tautan Cepat</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link
|
||||
href="#fitur"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Fitur
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="#komunitas"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Komunitas
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="#sumber-belajar"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Sumber Belajar
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="#testimoni"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Testimoni
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Sumber Belajar</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Video Tutorial
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Artikel
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Tantangan Koding
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Sharing Session
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Bahasa Pemrograman</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/10 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-400/20">
|
||||
PHP
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-600/10 dark:bg-yellow-900/30 dark:text-yellow-400 dark:ring-yellow-400/20">
|
||||
JavaScript
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/10 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-400/20">
|
||||
Python
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/10 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-400/20">
|
||||
C#
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/10 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-400/20">
|
||||
Java
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-cyan-50 px-2 py-1 text-xs font-medium text-cyan-700 ring-1 ring-inset ring-cyan-600/10 dark:bg-cyan-900/30 dark:text-cyan-400 dark:ring-cyan-400/20">
|
||||
Go
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-orange-50 px-2 py-1 text-xs font-medium text-orange-700 ring-1 ring-inset ring-orange-600/10 dark:bg-orange-900/30 dark:text-orange-400 dark:ring-orange-400/20">
|
||||
Rust
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/10 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-400/20">
|
||||
HTML
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-bold mb-2">Newsletter</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Dapatkan update terbaru dari kami
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email Anda"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1 text-sm shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50
|
||||
font-bold text-black hover:text-white cursor-pointer"
|
||||
>
|
||||
Daftar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t pt-8 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
|
||||
Handal, Namun Enggan Ngoding. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button, MenuIcon, XIcon } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SimpleThemeToggle } from './simple-theme-toggle';
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 10);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-50 w-full transition-all duration-300',
|
||||
isScrolled
|
||||
? 'bg-background/80 backdrop-blur-md border-b shadow-sm'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative overflow-hidden rounded">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/logo.webp"
|
||||
alt="IMPHNEN"
|
||||
width={64}
|
||||
height={64}
|
||||
className="object-cover"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
<Link href="#fitur" className="text-sm font-medium relative group">
|
||||
<span className="transition-colors hover:text-primary">Fitur</span>
|
||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
|
||||
</Link>
|
||||
<Link
|
||||
href="#komunitas"
|
||||
className="text-sm font-medium relative group"
|
||||
>
|
||||
<span className="transition-colors hover:text-primary">
|
||||
Komunitas
|
||||
</span>
|
||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
|
||||
</Link>
|
||||
<Link
|
||||
href="#sumber-belajar"
|
||||
className="text-sm font-medium relative group"
|
||||
>
|
||||
<span className="transition-colors hover:text-primary">
|
||||
Sumber Belajar
|
||||
</span>
|
||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
|
||||
</Link>
|
||||
<Link
|
||||
href="#testimoni"
|
||||
className="text-sm font-medium relative group"
|
||||
>
|
||||
<span className="transition-colors hover:text-primary">
|
||||
Testimoni
|
||||
</span>
|
||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SimpleThemeToggle />
|
||||
|
||||
<Button
|
||||
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
|
||||
onClick={() => router.push('/signin')}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<XIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<MenuIcon className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t bg-background/95 backdrop-blur-md">
|
||||
<nav className="container flex flex-col py-4 text-center">
|
||||
<Link
|
||||
href="#fitur"
|
||||
className="py-3 text-sm font-medium border-b border-border/50"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Fitur
|
||||
</Link>
|
||||
<Link
|
||||
href="#komunitas"
|
||||
className="py-3 text-sm font-medium border-b border-border/50"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Komunitas
|
||||
</Link>
|
||||
<Link
|
||||
href="#sumber-belajar"
|
||||
className="py-3 text-sm font-medium border-b border-border/50"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Sumber Belajar
|
||||
</Link>
|
||||
<Link
|
||||
href="#testimoni"
|
||||
className="py-3 text-sm font-medium"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Testimoni
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
onClick={() => router.push('/signin')}
|
||||
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ThemeProviderProps } from 'next-themes';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function Providers({ children }: ThemeProviderProps) {
|
||||
return <QueryProvider>{children}</QueryProvider>;
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
function QueryProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { ThemeProviderProps } from 'next-themes';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -1,11 +1,9 @@
|
||||
import { poppinsFont } from '@/lib/fonts';
|
||||
import '@/styles/globals.css';
|
||||
import { cn } from '@utils';
|
||||
import { type Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import Footer from './_components/footer';
|
||||
import { Header } from './_components/header';
|
||||
import { ThemeProvider } from './_components/theme-provider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
import { Providers } from './_components/providers';
|
||||
import { Toaster } from './_components/toaster';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'IMPHNEN - Ingin Menjadi Programmer Handal?',
|
||||
@@ -19,19 +17,16 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="id" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider
|
||||
<body className={cn(poppinsFont.className, 'antialiased')}>
|
||||
<Providers
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
{children}
|
||||
<Toaster richColors />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{ "value": "100K+", "label": "Member Aktif" },
|
||||
{ "value": "50+", "label": "Event Bulanan" },
|
||||
{ "value": "100+", "label": "Mentor Profesional" },
|
||||
{ "value": "5K+", "label": "Diskusi Mingguan" }
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
[
|
||||
{
|
||||
"iconName": "tabler:brand-facebook",
|
||||
"title": "Facebook Group",
|
||||
"description": "Bergabunglah dengan grup Facebook kami untuk diskusi santai dan berbagi artikel menarik.",
|
||||
"buttonText": "Gabung Sekarang",
|
||||
"buttonLink": "https://facebook.com/groups/programmerhandal"
|
||||
},
|
||||
{
|
||||
"iconName": "tabler:brand-instagram",
|
||||
"title": "Instagram",
|
||||
"description": "Ikuti kami di Instagram untuk tips programming, konten inspiratif, dan info event terbaru.",
|
||||
"buttonText": "Follow Kami",
|
||||
"buttonLink": "https://www.instagram.com/imphnen.dev"
|
||||
},
|
||||
{
|
||||
"iconName": "tabler:brand-discord-filled",
|
||||
"title": "Discord Server",
|
||||
"description": "Diskusikan langsung dengan sesama programmer dan dapatkan bantuan langsung dari para ahli.",
|
||||
"buttonText": "Join Server",
|
||||
"buttonLink": "https://discord.com/invite/imphnen"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"name": "IMPHNEN X GDG Medan",
|
||||
"description": "Join us for the Google Cloud Roadshow: Build with AI in Medan! This is your chance to dive deep into the latest advancements in cloud technology and AI, guided by industry experts.",
|
||||
"start_date": "2025-04-26T13:00:00+07:00",
|
||||
"end_date": "2025-04-26T17:00:00+07:00",
|
||||
"thumbnail": "https://scontent.fsoc1-2.fna.fbcdn.net/v/t39.30808-6/492363939_2132158393873766_2304913620264481070_n.jpg?_nc_cat=105&ccb=1-7&_nc_sid=127cfc&_nc_eui2=AeFe43oCdy2FOIVI6pr_grkO_q5XyVVxt5v-rlfJVXG3m9KgINIp9lo_vzXP4CtAAFJviDNJdDPaPyGnz7B8-gfE&_nc_ohc=bv3GExoL4pUQ7kNvwEpV7xQ&_nc_oc=Adk45dIpzgFxCHgtQKDTo99Sewj0ZMO4LX1cNEI2Dj7VeQDp8jd1RzYnG_UwsKkaTug&_nc_zt=23&_nc_ht=scontent.fsoc1-2.fna&_nc_gid=ZPKg-Y9FLRXcDiSak5WcLA&oh=00_AfL7BFg5g-q6MALMRFUlgaUWK8jHxQVTHdZ_1ezxBthXfw&oe=68354615",
|
||||
"type": "onsite",
|
||||
"price": 0,
|
||||
"location": "Magnificient, Medan",
|
||||
"detail_link": "https://n8n.gdgmedan.com/form/8466f60d-94b0-42a6-93aa-8738ae4ab5df?fbclid=IwY2xjawKcQoNleHRuA2FlbQIxMABicmlkETFnQ1RvSU5aWVhpWldaOXBjAR7LUWa_npCLb-QSfk1QFTl12tyw_eBhNO8oI8N2LvhwweeE5_41uQsARsEQGw_aem_1IP2vqrQcvjsvLCBy2C6Hg"
|
||||
},
|
||||
{
|
||||
"name": "IMPHNEN X Connect Citcom",
|
||||
"description": "Unleash the Future with AI: Decode the potential of artificial intelligence to revolutionize your strategies, unchain innovation, and gain a powerful competitive edge in today's fast-evolving business landscape. Harness the transformative capabilities of AI to outsmart competitors, optimize operations, and drive sustainable growth.",
|
||||
"start_date": "2025-04-22T13:00:00+07:00",
|
||||
"end_date": "2025-04-22T17:00:00+07:00",
|
||||
"thumbnail": "https://scontent.fsoc1-2.fna.fbcdn.net/v/t39.30808-6/490432625_2122941998128739_5372979458124718061_n.jpg?stp=dst-jpg_p526x296_tt6&_nc_cat=105&ccb=1-7&_nc_sid=aa7b47&_nc_eui2=AeGc_W_0Gd3XY9X9ImKz1h1HpYKlinPYX-qlgqWKc9hf6k75HObQiJ8A8m6BZOIe2OrVQjjyXnHq08Rc4JuQWeCQ&_nc_ohc=uoSae0n37k0Q7kNvwHch8E5&_nc_oc=AdngSKtrCdZ4H3hlHh-9cttOnaKyrwiFEcm88kjp0gaflvQ65LUp4OT45-XVGDAoIo8&_nc_zt=23&_nc_ht=scontent.fsoc1-2.fna&_nc_gid=YGkoLGAxNuB0uF3C9hSYGQ&oh=00_AfLRkrzG7DRYa3HRuy9aXfUfG6zLkRBDj9LxqXKOfF9x7Q&oe=683546E0",
|
||||
"type": "onsite",
|
||||
"price": 0,
|
||||
"location": "El Hotel, Bandung",
|
||||
"detail_link": "/events/1"
|
||||
},
|
||||
{
|
||||
"name": "JVM Meetup #64",
|
||||
"description": "Join us for an insightful talk where we’ll explore how AI is reshaping the payment industry. Whether you’re into tech, AI, or just curious about the future of transactions, this is a must-attend event!",
|
||||
"start_date": "2025-04-30T13:00:00+07:00",
|
||||
"end_date": "2025-04-30T17:00:00+07:00",
|
||||
"thumbnail": "https://scontent.fsoc1-1.fna.fbcdn.net/v/t39.30808-6/494158983_2136830150073257_5259003432367969457_n.jpg?_nc_cat=100&ccb=1-7&_nc_sid=833d8c&_nc_eui2=AeGcneO8ihnXyQ_7WV93z7Onz0UPVTr-bf_PRQ9VOv5t_wI-el8xouZ-Z4TKGMMlBPi21d9AA55SP54MHkzO4rx1&_nc_ohc=3Q9NKGIkpT4Q7kNvwHKzwXr&_nc_oc=AdkRJ--zwRGcfX_F2eIgx0qhK-N3OkhwqRwujFhgSRWy5mH_SdIyBmwW8PHHXp71ZUM&_nc_zt=23&_nc_ht=scontent.fsoc1-1.fna&_nc_gid=Xi30SjzSBAU1XN9rRxp0VA&oh=00_AfKvjd9LTMfOwfPLD43U8lKYEr4KABecj336J8hAY8ekMg&oe=68353105",
|
||||
"type": "onsite",
|
||||
"price": 0,
|
||||
"location": "El Hotel, Bandung",
|
||||
"detail_link": "https://lu.ma/422jkgz0?utm_campaign=jvmmeetup&utm_medium=social%2C%20event%2C%20meetup&utm_source=google%2C%20facebook%2C%20linkedin%2C%20instagram"
|
||||
},
|
||||
{
|
||||
"name": "JVM Meetup #65",
|
||||
"description": "Di era digital yang terus berkembang, Artificial Intelligence (AI) bukan lagi teknologi masa depantapi alat bantu masa kini yang siap mendongkrak efisiensi, kreativitas, dan produktivitas kita semua, terutama para penggiat IT dan pelaku usaha!",
|
||||
"start_date": "2025-05-15T13:00:00+07:00",
|
||||
"end_date": "2025-05-15T17:00:00+07:00",
|
||||
"thumbnail": "https://scontent.fsoc1-1.fna.fbcdn.net/v/t39.30808-6/495382435_2144684952621110_8304851620615091109_n.jpg?_nc_cat=104&ccb=1-7&_nc_sid=833d8c&_nc_eui2=AeHwnU4KixfUsslo3M90ahT8GwMEI7zWSnQbAwQjvNZKdP3RHiUjdDZtuF1dIMvorL0nW0rDGOD6tPDi0i9ERK96&_nc_ohc=R1NyXnu-FKEQ7kNvwGbO-iN&_nc_oc=Adluf-uw1tmzkNME9vNhwkTv5-x_GHMfsLj1bzGyYds_ODq3UnWMq2DthyUisF9e3gU&_nc_zt=23&_nc_ht=scontent.fsoc1-1.fna&_nc_gid=kykMk9nPtcrtk-InL6tF6w&oh=00_AfLZSrEPpV83lVFRMz19n9RzfnsM4o7K7TijpdAjWYViiA&oe=6835231B",
|
||||
"type": "onsite",
|
||||
"price": 0,
|
||||
"location": "Telkom Landmark Tower (Lt.31)",
|
||||
"detail_link": "s.id/jvm65"
|
||||
}
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
[
|
||||
{
|
||||
"iconName": "tabler:device-laptop",
|
||||
"title": "Belajar Tanpa Koding",
|
||||
"description": "Pelajari konsep programming dengan cara yang mudah dipahami tanpa harus menulis kode yang rumit."
|
||||
},
|
||||
{
|
||||
"iconName": "tabler:users",
|
||||
"title": "Komunitas Supportif",
|
||||
"description": "Bergabunglah dengan komunitas programmer Indonesia yang siap membantu dan berbagi pengalaman."
|
||||
},
|
||||
{
|
||||
"iconName": "tabler:book",
|
||||
"title": "Tutorial Interaktif",
|
||||
"description": "Akses tutorial interaktif yang membuat konsep programming lebih mudah untuk dipahami."
|
||||
},
|
||||
{
|
||||
"iconName": "tabler:code",
|
||||
"title": "Proyek Praktis",
|
||||
"description": "Terapkan pengetahuan Anda dalam proyek nyata dengan panduan langkah demi langkah."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"communityLabel": "Komunitas Programmer Indonesia",
|
||||
"headingLine1": "Programmer Handal",
|
||||
"headingLine2": "Enggan Ngoding",
|
||||
"description": "Komunitas programmer terbesar di Indonesia, tempat berbagi meme, tutorial, pengalaman dan yapping",
|
||||
"buttons": {
|
||||
"join": "Join Komunitas",
|
||||
"joinUrl": "#community",
|
||||
"explore": "Explore Event",
|
||||
"exploreUrl": "/events"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[
|
||||
{ "value": "180K+", "label": "Member" },
|
||||
{ "value": "500+", "label": "Tutorial" },
|
||||
{ "value": "500+", "label": "Meme Harian" },
|
||||
{ "value": "24/7", "label": "Yapping" }
|
||||
]
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
[
|
||||
{
|
||||
"icon": "tabler:video",
|
||||
"title": "Video Tutorial",
|
||||
"description": "Belajar melalui tutorial video dari langkah awal hingga mahir.",
|
||||
"buttonText": "Lihat Semua Video",
|
||||
"buttonLink": "#"
|
||||
},
|
||||
{
|
||||
"icon": "tabler:article",
|
||||
"title": "Artikel & Tutorial",
|
||||
"description": "Pelajari konsep programming melalui artikel yang disusun secara terstruktur.",
|
||||
"buttonText": "Baca Artikel",
|
||||
"buttonLink": "#"
|
||||
},
|
||||
{
|
||||
"icon": "tabler:brand-vscode",
|
||||
"title": "Tantangan Koding",
|
||||
"description": "Uji kemampuan koding kamu dengan tantangan yang menyenangkan dan menantang.",
|
||||
"buttonText": "Mulai Tantangan",
|
||||
"buttonLink": "#"
|
||||
},
|
||||
{
|
||||
"icon": "tabler:device-desktop-share",
|
||||
"title": "Sharing Session",
|
||||
"description": "Ikuti sesi berbagi pengalaman dari programmer berpengalaman dan belajar dari pengalaman mereka.",
|
||||
"buttonText": "Jadwal Session",
|
||||
"buttonLink": "#"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"title": "Home",
|
||||
"link": "/"
|
||||
},
|
||||
{
|
||||
"title": "Event",
|
||||
"link": "/events"
|
||||
},
|
||||
{
|
||||
"title": "Testimoni",
|
||||
"link": "/testimonials"
|
||||
},
|
||||
{
|
||||
"title": "Roadmap",
|
||||
"link": "/roadmaps"
|
||||
},
|
||||
{
|
||||
"title": "Artikel",
|
||||
"link": "/articles"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"name": "Facebook",
|
||||
"icon": "FaFacebook",
|
||||
"color": "#1877F2",
|
||||
"description": "Join discussions and share knowledge",
|
||||
"link": "#"
|
||||
},
|
||||
{
|
||||
"name": "Discord",
|
||||
"icon": "FaDiscord",
|
||||
"color": "#5865F2",
|
||||
"description": "Real-time collaboration chat",
|
||||
"link": "#"
|
||||
},
|
||||
{
|
||||
"name": "Instagram",
|
||||
"icon": "FaInstagram",
|
||||
"color": "#E4405F",
|
||||
"description": "Daily coding tips & showcases",
|
||||
"link": "#"
|
||||
},
|
||||
{
|
||||
"name": "TikTok",
|
||||
"icon": "FaTiktok",
|
||||
"color": "#000000",
|
||||
"description": "Short coding tutorials",
|
||||
"link": "#"
|
||||
},
|
||||
{
|
||||
"name": "LinkedIn",
|
||||
"icon": "FaLinkedin",
|
||||
"color": "#0A66C2",
|
||||
"description": "Professional networking",
|
||||
"link": "#"
|
||||
}
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{ "value": "98%", "label": "Tingkat Kemalasan" },
|
||||
{ "value": "4.9/5", "label": "Rating Drama" },
|
||||
{ "value": "85%", "label": "Mendapat Pekerjaan" },
|
||||
{ "value": "24/7", "label": "Yapping" }
|
||||
]
|
||||
@@ -1,20 +1,44 @@
|
||||
[
|
||||
{
|
||||
"quote": "Sebagai seseorang yang awalnya buta sama sekali tentang programming, kini saya bisa membuat website sendiri dengan percaya diri. IMPHNEN benar-benar jadi pintu gerbang saya ke dunia web development!",
|
||||
"name": "Ega",
|
||||
"role": "Web Developer",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/97678571?v=4"
|
||||
"id": 1,
|
||||
"name": "Andi Pratama",
|
||||
"role": "Backend Developer",
|
||||
"text": "Komunitas ini sangat membantu perkembangan karir saya. Saya bisa belajar teknologi terbaru dan berkolaborasi dengan developer lain.",
|
||||
"image": "https://picsum.photos/100/100?random=1"
|
||||
},
|
||||
{
|
||||
"quote": "IMPHNEN bukan cuma komunitas, tapi juga jadi tempat saya berkembang sebagai backend engineer. Diskusi teknisnya berbobot, dan respon dari komunitas selalu cepat dan tepat sasaran.",
|
||||
"name": "Maulana",
|
||||
"role": "Backend Engineer",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/53475078?v=4"
|
||||
"id": 2,
|
||||
"name": "Sarah Wijaya",
|
||||
"role": "Frontend Engineer",
|
||||
"text": "Acara sharing session-nya sangat inspiratif. Saya jadi termotivasi untuk terus mengembangkan skill di bidang frontend development.",
|
||||
"image": "https://picsum.photos/100/100?random=2"
|
||||
},
|
||||
{
|
||||
"quote": "Sebagai fullstack engineer pemula, saya merasa sangat terbantu dengan komunitas Discord IMPHNEN. Materinya praktikal, pembahasannya jelas, dan member-nya selalu siap membantu.",
|
||||
"name": "Rasyid",
|
||||
"role": "Fullstack Engineer",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/49753444?v=4"
|
||||
"id": 3,
|
||||
"name": "Rizal Fauzi",
|
||||
"role": "Fullstack Developer",
|
||||
"text": "Bergabung di komunitas ini membuka banyak kesempatan networking dan project menarik. Sangat recommended untuk developer semua level!",
|
||||
"image": "https://picsum.photos/100/100?random=5"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Dewi Lestari",
|
||||
"role": "Mobile Developer",
|
||||
"text": "Materi workshop-nya praktis dan langsung applicable. Mentor-mentornya juga berpengalaman di industri.",
|
||||
"image": "https://picsum.photos/100/100?random=9"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Fajar Setiawan",
|
||||
"role": "DevOps Engineer",
|
||||
"text": "Komunitas yang solid dan saling support. Tidak pernah ragu untuk bertanya karena semua anggota sangat responsif.",
|
||||
"image": "https://picsum.photos/100/100?random=10"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Budi Santoso",
|
||||
"role": "UI/UX Designer",
|
||||
"text": "Kolaborasi antara designer dan developer di komunitas ini sangat smooth. Banyak belajar best practices untuk workflow yang lebih baik.",
|
||||
"image": "https://picsum.photos/100/100?random=6"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const REFRESH_TOKEN_NAME = '__imphnen_refresh_token__';
|
||||
const ACCESS_TOKEN_NAME = '__imphnen_access_token__';
|
||||
|
||||
export async function setRefreshToken(refreshToken: string) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(REFRESH_TOKEN_NAME, refreshToken);
|
||||
}
|
||||
|
||||
export async function setAccessToken(accessToken: string) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(ACCESS_TOKEN_NAME, accessToken);
|
||||
}
|
||||
|
||||
export async function getRefreshToken() {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get(REFRESH_TOKEN_NAME)?.value;
|
||||
}
|
||||
|
||||
export async function getAccessToken() {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get(ACCESS_TOKEN_NAME)?.value;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { paths } from '@/openapi-types';
|
||||
import createClient from 'openapi-fetch';
|
||||
|
||||
export const fetcher = createClient<paths>({
|
||||
baseUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Bai_Jamjuree, Poppins } from 'next/font/google';
|
||||
|
||||
export const baiJamjureeFont = Bai_Jamjuree({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const poppinsFont = Poppins({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
display: 'swap',
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export async function getRemoteIp() {
|
||||
const hdrs = await headers();
|
||||
const xff = hdrs.get('x-forwarded-for');
|
||||
if (!xff) return undefined;
|
||||
|
||||
// 'x-forwarded-for' can be a comma-separated list of IPs
|
||||
const ips = xff.split(',').map((ip) => ip.trim());
|
||||
return ips[0] || undefined;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { paths } from '@/openapi-types';
|
||||
import createFetchClient from 'openapi-fetch';
|
||||
import createClient from 'openapi-react-query';
|
||||
|
||||
const fetchClient = createFetchClient<paths>({
|
||||
baseUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
export const rpc = createClient(fetchClient);
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+118
-5
@@ -8,14 +8,18 @@
|
||||
"name": "@imphnen-frontend-service/shadcn-ui",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.503.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.4"
|
||||
"tailwindcss": "^4.1.4",
|
||||
"zod": "^3.25.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tw-animate-css": "^1.2.8"
|
||||
@@ -446,6 +450,18 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz",
|
||||
"integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
@@ -461,10 +477,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
|
||||
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"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.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"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-slot": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
||||
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
@@ -759,6 +821,12 @@
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.4.tgz",
|
||||
@@ -1464,6 +1532,35 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.56.4",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz",
|
||||
"integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||
@@ -1513,6 +1610,13 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1649,6 +1753,15 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.28",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.28.tgz",
|
||||
"integrity": "sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,18 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.503.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.4"
|
||||
"tailwindcss": "^4.1.4",
|
||||
"zod": "^3.25.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tw-animate-css": "^1.2.8"
|
||||
|
||||
@@ -3,23 +3,21 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib/cn';
|
||||
import { cn } from '../lib';
|
||||
|
||||
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',
|
||||
'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px] transition-colors duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
'bg-white hover:text-primary-600 hover:bg-gray-50 text-primary-500 shadow-md',
|
||||
text: 'bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
|
||||
bordered:
|
||||
'border border-primary-500 hover:border-primary-600 bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
|
||||
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
|
||||
danger: 'bg-danger-100 hover:bg-danger-200 text-danger-500 shadow-md',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
@@ -29,7 +27,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
variant: 'primary',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib';
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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 '../lib';
|
||||
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,
|
||||
};
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './button';
|
||||
export * from './card';
|
||||
export * from './form';
|
||||
export * from './icons';
|
||||
export * from './input';
|
||||
export * from './label';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib';
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib';
|
||||
|
||||
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 };
|
||||
@@ -1,9 +1,73 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tw-animate-css";
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-primary-50: #f0f8ff;
|
||||
--color-primary-100: #e1f0fd;
|
||||
--color-primary-200: #bce1fb;
|
||||
--color-primary-300: #81cbf8;
|
||||
--color-primary-400: #3eb0f2;
|
||||
--color-primary-500: #23a1eb;
|
||||
--color-primary-600: #0877c1;
|
||||
--color-primary-700: #085f9c;
|
||||
--color-primary-800: #0b5181;
|
||||
--color-primary-900: #0f446b;
|
||||
--color-primary-950: #0a2b47;
|
||||
|
||||
--color-neutral-50: #f6f6f6;
|
||||
--color-neutral-100: #e7e7e7;
|
||||
--color-neutral-200: #d1d1d1;
|
||||
--color-neutral-300: #b0b0b0;
|
||||
--color-neutral-400: #888888;
|
||||
--color-neutral-500: #6d6d6d;
|
||||
--color-neutral-600: #5d5d5d;
|
||||
--color-neutral-700: #4f4f4f;
|
||||
--color-neutral-800: #454545;
|
||||
--color-neutral-900: #3d3d3d;
|
||||
--color-neutral-950: #2b2b2b;
|
||||
|
||||
--color-success-100: #e0fbd8;
|
||||
--color-success-200: #bcf8b0;
|
||||
--color-success-300: #8eea85;
|
||||
--color-success-400: #63d564;
|
||||
--color-success-500: #35ba43;
|
||||
--color-success-600: #269f3e;
|
||||
--color-success-700: #1a8439;
|
||||
--color-success-800: #106b32;
|
||||
--color-success-900: #0b592f;
|
||||
|
||||
--color-info-100: #ccfcfe;
|
||||
--color-info-200: #9bf3fd;
|
||||
--color-info-300: #67e3fb;
|
||||
--color-info-400: #42cdf8;
|
||||
--color-info-500: #04acf3;
|
||||
--color-info-600: #0185d0;
|
||||
--color-info-700: #0264af;
|
||||
--color-info-800: #01478d;
|
||||
--color-info-900: #003375;
|
||||
|
||||
--color-warning-100: #fffcd3;
|
||||
--color-warning-200: #fffaa9;
|
||||
--color-warning-300: #fff67d;
|
||||
--color-warning-400: #fff25d;
|
||||
--color-warning-500: #ffed27;
|
||||
--color-warning-600: #dbc91d;
|
||||
--color-warning-700: #b7a714;
|
||||
--color-warning-800: #93850b;
|
||||
--color-warning-900: #7a6d07;
|
||||
|
||||
--color-danger-100: #ffe8da;
|
||||
--color-danger-200: #ffcbb3;
|
||||
--color-danger-300: #ffaa8d;
|
||||
--color-danger-400: #ff8870;
|
||||
--color-danger-500: #ff5242;
|
||||
--color-danger-600: #da3030;
|
||||
--color-danger-700: #b7212d;
|
||||
--color-danger-800: #93152a;
|
||||
--color-danger-900: #7a0c27;
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -76,40 +140,6 @@
|
||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.379 0.146 265.522);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--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.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -127,6 +157,7 @@
|
||||
padding-right: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
|
||||
Generated
+317
-6
@@ -12,7 +12,10 @@
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@redocly/ajv": "^8.11.2",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tanstack/react-store": "^0.7.0",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
@@ -24,6 +27,8 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "~15.2.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"openapi-fetch": "^0.14.0",
|
||||
"openapi-react-query": "^0.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
@@ -90,6 +95,7 @@
|
||||
"jiti": "2.4.2",
|
||||
"jsdom": "~22.1.0",
|
||||
"nx": "20.8.1",
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^2.6.2",
|
||||
"serve": "^14.2.4",
|
||||
@@ -4157,6 +4163,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@marsidev/react-turnstile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.1.0.tgz",
|
||||
"integrity": "sha512-X7bP9ZYutDd+E+klPYF+/BJHqEyyVkN4KKmZcNRr84zs3DcMoftlMAuoKqNSnqg0HE7NQ1844+TLFSJoztCdSA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2 || ^18.0.0 || ^19.0",
|
||||
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mdx-js/react": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz",
|
||||
@@ -7590,10 +7606,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
|
||||
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"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.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"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-slot": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
||||
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
@@ -7627,6 +7689,111 @@
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js-replace": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/config": {
|
||||
"version": "0.22.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz",
|
||||
"integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core": {
|
||||
"version": "1.34.3",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.3.tgz",
|
||||
"integrity": "sha512-3arRdUp1fNx55itnjKiUhO6t4Mf91TsrTIYINDNLAZPS0TPd5YpiXRctwjel0qqWoOOhjA34cZ3m4dksLDFUYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/ajv": "^8.11.2",
|
||||
"@redocly/config": "^0.22.0",
|
||||
"colorette": "^1.2.0",
|
||||
"https-proxy-agent": "^7.0.5",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"js-yaml": "^4.1.0",
|
||||
"minimatch": "^5.0.1",
|
||||
"pluralize": "^8.0.0",
|
||||
"yaml-ast-parser": "0.0.43"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0",
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/agent-base": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/colorette": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
||||
@@ -10826,7 +10993,7 @@
|
||||
"version": "19.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
||||
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
@@ -14825,6 +14992,13 @@
|
||||
"url": "https://github.com/chalk/chalk-template?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/char-regex": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
|
||||
@@ -18394,7 +18568,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
@@ -20438,6 +20611,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.1.0.tgz",
|
||||
"integrity": "sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -22808,6 +22994,16 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-levenshtein": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -26121,6 +26317,99 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-fetch": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.0.tgz",
|
||||
"integrity": "sha512-PshIdm1NgdLvb05zp8LqRQMNSKzIlPkyMxYFxwyHR+UlKD4t2nUjkDhNxeRbhRSEd3x5EUNh2w5sJYwkhOH4fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"openapi-typescript-helpers": "^0.0.15"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-react-query": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-react-query/-/openapi-react-query-0.5.0.tgz",
|
||||
"integrity": "sha512-VtyqiamsbWsdSWtXmj/fAR+m9nNxztsof6h8ZIsjRj8c8UR/x9AIwHwd60IqwgymmFwo7qfSJQ1ZzMJrtqjQVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"openapi-typescript-helpers": "^0.0.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.25.0",
|
||||
"openapi-fetch": "^0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.8.0.tgz",
|
||||
"integrity": "sha512-1EeVWmDzi16A+siQlo/SwSGIT7HwaFAVjvMA7/jG5HMLSnrUOzPL7uSTRZZa4v/LCRxHTApHKtNY6glApEoiUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/openapi-core": "^1.34.3",
|
||||
"ansi-colors": "^4.1.3",
|
||||
"change-case": "^5.4.4",
|
||||
"parse-json": "^8.3.0",
|
||||
"supports-color": "^10.0.0",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-typescript": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.x"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript-helpers": {
|
||||
"version": "0.0.15",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz",
|
||||
"integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"index-to-position": "^1.1.0",
|
||||
"type-fest": "^4.39.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/supports-color": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.0.0.tgz",
|
||||
"integrity": "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
@@ -26864,6 +27153,16 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/polished": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
|
||||
@@ -28404,7 +28703,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -32768,6 +33066,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js-replace": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
||||
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/url-join": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
|
||||
@@ -34639,6 +34943,13 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml-ast-parser": {
|
||||
"version": "0.0.43",
|
||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"landing:dev": "nx serve landing",
|
||||
"landing:build": "nx build landing",
|
||||
"landing:prod": "cd ./dist/apps/landing && npx next start",
|
||||
"landing:openapi": "npx openapi-typescript https://api.imphnen.dev/openapi.json -o ./apps/landing/src/openapi-types.ts",
|
||||
"ui:test": "nx test ui",
|
||||
"ui:build": "nx build ui",
|
||||
"ui:storybook": "nx storybook ui"
|
||||
@@ -27,7 +28,10 @@
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@redocly/ajv": "^8.11.2",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tanstack/react-store": "^0.7.0",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
@@ -39,6 +43,8 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "~15.2.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"openapi-fetch": "^0.14.0",
|
||||
"openapi-react-query": "^0.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
@@ -105,6 +111,7 @@
|
||||
"jiti": "2.4.2",
|
||||
"jsdom": "~22.1.0",
|
||||
"nx": "20.8.1",
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^2.6.2",
|
||||
"serve": "^14.2.4",
|
||||
|
||||
Reference in New Issue
Block a user