Compare commits

..
Author SHA1 Message Date
Rasyid b3bf575402 feat: update testimonial popup text and improve form layout 2025-06-19 12:46:34 +07:00
Rasyid 74046efebe feat: implement testimonial popup and avatar components; update layout and roadmap items 2025-06-19 09:26:07 +07:00
Naufal Azmi b7282f9c99 Resend OTP (#34)
* feat: responsive

* fix: success register

* feat: auth login responsive && auth login implementation

* fix: login page button disabled & register page

* fix: conflict

* fix: button disabled when loading

* feat: register and otp integration

* Update middleware.ts

* feat: OTP Resend
2025-06-18 18:50:06 +07:00
Rasyid 2c347d1cc5 feat: add ui for roadmap ui (#35)
* fix: correct link for Roadmap navigation item

* feat: add feature request and projects voting components

* fix: correct spelling of 'Certificate' in ProjectsVote component

* feat: add Request Feature popup with responsive dialog and drawer components

* feat: enhance Header component with mobile menu animations and improved navigation styling

* refactor: simplify Header component by removing unused scroll handling and improving mobile menu structure

* fix: replace motion span with static span for header underline

* fix: update FeatureRequestCTA text for clarity and adjust page layout margins

* fix: remove DialogDescription from RequestFeaturePopup and update textarea placeholder for clarity

* fix: remove DrawerDescription from RequestFeaturePopup for cleaner UI

* fix: remove unused icon exports from icons

* fix: replace XIcon with LuX in DialogClose for consistency

* fix: remove unused icon export from index for cleaner module structure
2025-06-18 18:40:19 +07:00
Rasyid 791f43986d feat: landing auth integration & landing ui improvements (#33)
* feat: setup openapi libs for landing

* feat: seperate landing layouts

* feat: movee landing providers

* feat: add auth layout

* feat: implement cookie management for access and refresh tokens

* feat: replace Inter font with Bai Jamjuree in layout and add font utility

* feat: add signin

* feat: add Poppins font to the font utility

* feat: expand theme colors in index.css for improved styling options

* feat: update dependencies and enhance button and form components with new variants and context

* feat: move old components to __old__ dir

* feat: add react query support

* feat: hide the footer

* fix: header style

* feat: replace Image component with LogoSimple in Header and update Logo component to accept props

* fix: update label for tutorial stat in hero-stats.json

* feat: implement EventsPage component with event listing and details

* feat: add signup button and page

* feat: add HERO_CONTENT data and integrate into Hero component

* fix: update navigations import to uppercase and add missing Testimoni entry

* feat: implement testimonials display and add submission page

* fix: correct link path for testimonial submission

* feat: add communites component and integrate social links from JSON data

* fix: adjust grid gap in Communities component for better layout

* fix: update font imports in layout components for consistency

* fix: replace text logo with LogoSimple component in Footer and restore Footer in Layout

* feat: add TestimonialSection component and update testimonials data

* fix: update section ID from 'komunitas' to 'community' for consistency

* feat: add CTASection component with interactive elements and background pattern

* fix: update join URL in hero content for consistency

* fix: update text in CTASection for improved emphasis and consistency

* feat: add CommunitySection component and integrate it into the main page

* feat: update Footer component to use dynamic navigation and social links

* feat: implement HeroSection component with dynamic content and animations

* refactor: replace hardcoded testimonials with dynamic data from JSON

* feat: add signup page

* feat: integrate Turnstile captcha verification in signup process

* feat: implement verification page

* feat: add signin link to the signup page

* feat: add "Forgot Password?" link to the signin form

* refactor: clean up unused ThemeProvider code and CSS variables

* feat: add base styles for borders and background to improve UI consistency

* feat: implement forgot password functionality with validation and UI components

* fix: update password validation rules for clarity and consistency

* feat: enable rich colors for Toaster component in layout

* feat: implement reset password functionality with validation and UI components

* feat: implement Turnstile captcha verification for forgot password and signup actions

* refactor: remove console log and enhance error handling in signin and resend OTP actions

* feat: add navigation entries for Roadmap and Artikel sections

* feat: create initial Page component for articles section

* feat: update footer component with additional social media links and icons

* feat: remove all unused components in landing

* chore: add @radix-ui/react-label deps

* feat: wrap VerificationTabs in Suspense

* feat: update .env.example to include TURNSTILE_SECRET_KEY and correct NEXT_PUBLIC_TURNSTILE_SITEKEY
2025-05-30 11:57:17 +07:00
63 changed files with 2741 additions and 1022 deletions
@@ -0,0 +1,10 @@
import { useSendOTP } from '@imphnen-frontend-service/utils';
export const useResendOtpHook = () => {
const { resendOTP, isLoading } = useSendOTP();
return {
resendOTP
};
};
@@ -1,12 +1,38 @@
import { FC, ReactElement } from 'react';
import { FC, ReactElement, useEffect, useState } from 'react';
import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
import { useOtpHook } from '../../../_hooks/use-otp';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useSearchParams } from 'react-router-dom';
import { useResendOtpHook } from '../../../_hooks/use-resend-otp';
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useOtpHook()
const [ searchParams ] = useSearchParams()
const [ disabled, setDisabled] = useState(true);
const [ timeLeft, setTimeLeft ] = useState(5 * 60);
const { resendOTP } = useResendOtpHook()
useEffect(() => {
if (disabled) {
const interval = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
clearInterval(interval);
setDisabled(false);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}
}, [disabled]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
@@ -26,6 +52,13 @@ export const Components: FC = (): ReactElement => {
maxLength={6}
control={form.control}
/>
<Button type="button" className="mt-2" disabled={disabled} onClick={
() => {
resendOTP({email: searchParams.get("email") || ""})
setDisabled(true)
setTimeLeft(5 * 60)
}
}>Kirim ulang otp {disabled? `(${formatTime(timeLeft)})`: ""}</Button>
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
</form>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ const mappingPublicRoutes = [
'/auth/login',
'/auth/forgot',
'/auth/forgot/otp',
'/auth/register',
'/auth/register',
'/auth/register/otp',
'/auth/register/success',
'/auth/new-password',
+3 -1
View File
@@ -1,2 +1,4 @@
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_TURNSTILE_SITEKEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=
NEXT_PUBLIC_TURNSTILE_SITEKEY=
@@ -1,6 +1,8 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import { getRemoteIp } from '@/lib/headers';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import {
forgotPasswordValidationSchema,
ForgotPasswordValidationType,
@@ -10,6 +12,14 @@ 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: {
@@ -17,8 +27,6 @@ export async function ForgotPasswordAction(
},
});
console.log(data, error);
if (error) throw new Error(error.message);
return data;
@@ -11,7 +11,7 @@ import {
Input,
} from '@components';
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
import { useRef } from 'react';
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';
@@ -20,54 +20,74 @@ import { ForgotPasswordValidationType } from '../_validation/forgot-password-val
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 onSubmit = (values: ForgotPasswordValidationType) => {
mutate(values);
const handleFirstStep = (values: ForgotPasswordValidationType) => {
setEmailValue(values.email);
setStep(2);
};
const handleSecondStep = () => {
mutate({ email: emailValue, token: form.getValues('token') });
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full space-y-4">
<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>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{step === 1 && (
<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',
}}
/>
{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}
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'
)}
@@ -10,7 +10,11 @@ export function usePostForgotPassowrd(
mutationFn: ForgotPasswordAction,
onSuccess: ({ message }) => {
form.reset();
toast(message);
toast.success(message);
},
onError: ({ message }) => {
form.reset();
toast.error(message);
},
});
}
+1 -8
View File
@@ -1,17 +1,10 @@
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'
)}
>
<div className="bg-background/20 relative flex min-h-[100dvh] items-center justify-center p-4">
<AnimatedBackground />
<div className="z-10 w-full">
<Card className="bg-background/90 mx-auto w-full max-w-[360px] p-6 shadow-xl backdrop-blur-lg sm:max-w-md sm:p-8">
@@ -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>
</>
);
}
@@ -8,8 +8,6 @@ import {
} from '../_validation/signin-validation';
export async function SigninAction(request: SignInValidationType) {
console.log(request);
const validRequest = signInValidationSchema.parse(request);
const { data } = await fetchPostSignin(validRequest);
@@ -1,5 +1,6 @@
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';
@@ -8,11 +9,20 @@ export function usePostSignin(form: ReturnType<typeof useFormSignin>) {
return useMutation({
mutationFn: SigninAction,
onSuccess: () => {
router.push('/');
},
onError: () => {
onError: (error, variables) => {
form.resetField('password');
if (error.message.includes('not active')) {
setTimeout(() => {
router.push(`/verification?ref=${variables.email}`);
}, 750);
}
toast.error(error.message);
},
});
}
@@ -1,20 +1,23 @@
'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 { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
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 isCapchaValidationValid = await fetchPostverifyTurnstile(
validRequest.token
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const data = await fetchPostSignin(validRequest);
@@ -1,7 +1,8 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
import { getRemoteIp } from '@/lib/headers';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import {
resendOTPValidationSchema,
type ResendOTPValidationType,
@@ -9,20 +10,22 @@ import {
export async function resendOTPAction(request: ResendOTPValidationType) {
const validRequest = resendOTPValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValidationValid = await fetchPostverifyTurnstile(
validRequest.token
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const { data } = await fetcher.POST('/v1/auth/send-otp', {
const { data, error } = await fetcher.POST('/v1/auth/send-otp', {
body: {
email: validRequest.email,
},
});
console.log(data);
if (error) throw new Error(error.message);
return data?.message;
return data;
}
@@ -31,7 +31,7 @@ export function VerificationTabs() {
)}
onClick={() => setActiveTab('resend')}
>
Kirim Ulang OTP
Resend OTP
</button>
</div>
@@ -1,13 +1,18 @@
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
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>) {
const router = useRouter();
return useMutation({
mutationFn: resendOTPAction,
onSuccess: () => router.replace('/verification?success=true'),
onSuccess: ({ message }) => {
form.reset();
toast.success(message);
},
onError: ({ message }) => {
form.reset();
toast.error(message);
},
});
}
@@ -1,41 +0,0 @@
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[];
}
@@ -1,3 +1,4 @@
import { Suspense } from 'react';
import { LogoSimple } from '../../_components/logo';
import { VerificationTabs } from './_components/verification-tabs';
@@ -12,7 +13,9 @@ export default function Page() {
</p>
</div>
<VerificationTabs />
<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,130 +0,0 @@
'use client';
import HERO_STATS from '@/data/hero-stats.json';
import { Button, SparklesIcon } from '@components';
import { motion } from 'framer-motion';
import Image from 'next/image';
import { Fragment, useEffect, useState } from 'react';
export function Hero() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
<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
className="absolute top-1/4 -left-20 w-80 h-80 rounded-full bg-gradient-to-r from-primary/20 to-blue-400/20 blur-3xl"
style={{
transform: `translate(${scrollY * 0.1}px, ${scrollY * -0.05}px)`,
opacity: Math.max(0.2, 1 - scrollY * 0.001),
}}
/>
<div
className="absolute bottom-1/3 -right-20 w-80 h-80 rounded-full bg-gradient-to-r from-blue-400/20 to-primary/20 blur-3xl"
style={{
transform: `translate(${scrollY * -0.1}px, ${scrollY * 0.05}px)`,
opacity: Math.max(0.2, 1 - scrollY * 0.001),
}}
/>
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] bg-[size:40px_40px]" />
</div>
<div className="container px-4 md:px-6 relative">
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<motion.div
className="flex flex-col justify-center space-y-8"
initial={{ opacity: 0, y: 20 }}
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>
<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
</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.
</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>
<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')
}
>
<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>
</Button>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-6 sm:gap-8">
{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">
{value}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
{i < HERO_STATS.length - 1 && (
<div className="hidden sm:block h-10 border-r border-border mx-4" />
)}
</Fragment>
))}
</div>
</motion.div>
<motion.div
className="relative w-full lg:w-auto mx-auto lg:ml-auto"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Image
src="/logo.webp"
alt="logo"
width={600}
height={500}
className="w-full h-auto object-cover"
/>
</motion.div>
</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">
&ldquo;{t.quote}&rdquo;
</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>
);
}
@@ -2,6 +2,13 @@ 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 (
@@ -20,57 +27,31 @@ export default function Footer() {
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>
<FaFacebook className="size-6" />
</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>
<FaDiscord className="size-6" />
</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>
<FaInstagram className="size-6" />
</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>
<FaTiktok className="size-6" />
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-foreground"
>
<FaLinkedinIn className="size-6" />
</Link>
</div>
</div>
@@ -105,7 +86,7 @@ export default function Footer() {
</ul>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Community Patners</h3>
<h3 className="text-lg font-bold">Patners</h3>
<ul className="space-y-2"></ul>
</div>
</div>
@@ -4,107 +4,170 @@ import { LogoSimple } from '@/app/_components/logo';
import NAVIGATIONS from '@/data/navigations.json';
import { Button } from '@components';
import { cn } from '@utils';
import { AnimatePresence, motion } from 'framer-motion';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { usePathname, 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 pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
return () => {
document.body.style.overflow = 'auto';
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
}, [mobileMenuOpen]);
useEffect(() => {
setMobileMenuOpen(false);
}, [pathname]);
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>
<header className="sticky top-0 w-full z-50 bg-background/70">
<div className="container flex h-20 items-center justify-between">
<Link
href="/"
className="relative overflow-hidden rounded z-50"
aria-label="Home"
>
<LogoSimple className="w-24 md:w-28" />
</Link>
{/* 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"
className={cn(
'text-sm font-medium relative group px-1 py-2',
pathname === link ? 'text-primary' : 'text-foreground/90'
)}
>
<span className="transition-colors hover:text-primary">
<span className="transition-colors hover:text-primary block">
{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>
{pathname === link && (
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-primary-500" />
)}
</Link>
))}
</nav>
<div className="flex items-center gap-x-2">
<div className="hidden md:flex items-center gap-x-3">
<Button
onClick={() => router.push('/signin')}
className="hidden md:flex"
className="px-5 py-2 text-sm font-medium"
>
Masuk
</Button>
<Button
variant="secondary"
variant="bordered"
onClick={() => router.push('/signup')}
className="hidden md:flex"
className="px-5 py-2 text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30"
>
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)}
<button
onClick={() => setMobileMenuOpen((prev) => !prev)}
className="flex md:hidden relative z-50 p-2 rounded-lg hover:bg-muted transition-colors"
aria-label={mobileMenuOpen ? 'Tutup menu' : 'Buka menu'}
>
{mobileMenuOpen ? (
<LuX className="size-6" />
) : (
<LuMenu className="size-6" />
)}
</button>
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-60 bg-background flex flex-col"
>
<div className="container flex h-20 items-center justify-between">
<Link
href="/"
className="relative overflow-hidden rounded"
onClick={() => setMobileMenuOpen(false)}
>
<LogoSimple className="w-24" />
</Link>
<button
onClick={() => setMobileMenuOpen(false)}
className="p-2 rounded-lg hover:bg-muted transition-colors"
aria-label="Tutup menu"
>
<LuX className="size-6" />
</button>
</div>
<motion.nav
className="flex-1 flex flex-col items-center justify-center gap-6 py-10"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
>
{title}
</Link>
))}
{NAVIGATIONS.map(({ link, title }) => (
<Link
key={link}
href={link}
className={cn(
'text-2xl font-medium py-2 px-6 rounded-lg transition-colors',
pathname === link
? 'text-primary bg-primary/10'
: 'text-foreground hover:bg-muted'
)}
onClick={() => setMobileMenuOpen(false)}
>
{title}
</Link>
))}
</motion.nav>
<Button onClick={() => router.push('/signin')}>Login</Button>
</nav>
</div>
)}
<motion.div
className="container space-y-4 pb-10"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Button
onClick={() => {
setMobileMenuOpen(false);
router.push('/signin');
}}
className="w-full py-4 text-base"
>
Masuk
</Button>
<Button
variant="bordered"
onClick={() => {
setMobileMenuOpen(false);
router.push('/signup');
}}
className="w-full py-4 text-base shadow-lg shadow-primary/20"
>
Daftar
</Button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</header>
);
}
@@ -1,8 +1,9 @@
'use client';
import { Button, MoonIcon, SunIcon } from '@components';
import { Button } from '@components';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { LuMoon, LuSun } from 'react-icons/lu';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
@@ -28,9 +29,9 @@ export function ThemeToggle() {
className="focus-visible:ring-0 cursor-pointer"
>
{theme === 'dark' ? (
<SunIcon className="h-[1.2rem] w-[1.2rem]" />
<LuSun className="h-[1.2rem] w-[1.2rem]" />
) : (
<MoonIcon className="h-[1.2rem] w-[1.2rem]" />
<LuMoon className="h-[1.2rem] w-[1.2rem]" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
@@ -0,0 +1,35 @@
'use client';
import { motion } from 'framer-motion';
import { RequestFeaturePopup } from './request-feature-popup';
export function FeatureRequestCTA() {
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="bg-primary-500 rounded-xl px-4 py-8 md:p-8 mb-8 text-center mx-auto max-w-7xl"
>
<div className="max-w-3xl mx-auto">
<motion.h1
className="text-2xl md:text-3xl font-bold text-white mb-3"
initial={{ scale: 0.95 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring' }}
>
Punya Ide Bagus untuk IMPHNEN?
</motion.h1>
<motion.p
className="text-white/90 mb-6 text-lg text-balance"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
Bagikan ide fitur yang kamu inginkan dan vote untuk membuatnya nyata!
</motion.p>
<RequestFeaturePopup />
</div>
</motion.div>
);
}
@@ -0,0 +1,239 @@
'use client';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@components';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import { BiUpvote } from 'react-icons/bi';
import { FiCheckCircle } from 'react-icons/fi';
import { MdOutlineOpenInNew } from 'react-icons/md';
export default function ProjectsVote() {
const [upcomingItems, setUpcomingItems] = useState([
{
title: 'IMPHNEN Project Showcase',
description: 'Showcase projectmu ke member lain dan dapatkan feedback',
votes: 42,
voted: false,
},
{
title: 'IMPHNEN Meme Generator',
description: 'Bikin meme kocak kapanpun dengan mudah',
votes: 42,
voted: false,
},
]);
const inProgressItems = [
{
title: 'IMPHNEN Twibbon',
description: 'Buat Twibbon kece untuk profile media sosialmu',
},
{
title: 'IMPHNEN Certificate',
description: 'Cetak sertifikat keren secara instan untuk anggota IMPHNEN',
},
{
title: 'IMPHNEN Roadmap by Vote',
description: 'Usulkan ide fitur seru dan ajak anggota lain buat voting',
},
];
const completedItems = [
{
title: 'IMPHNEN Events Page',
description:
'Koleksi daftar event dan kolaborasi seru yang bisa kamu ikuti',
},
{
title: 'IMPHNEN Testimoni Page',
description: 'Berikan testimonial buat komunitas IMPHNEN',
},
];
const handleVote = (index: number) => {
const newItems = [...upcomingItems];
newItems[index] = {
...newItems[index],
votes: newItems[index].voted
? newItems[index].votes - 1
: newItems[index].votes + 1,
voted: !newItems[index].voted,
};
setUpcomingItems(newItems);
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2 } },
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-7xl mx-auto">
{/* Vote Now Column */}
<div className="space-y-6">
<div className="flex items-center gap-3 px-2">
<div className="flex items-center justify-center w-10 h-10 bg-amber-100 text-amber-600 rounded-lg">
🔥
</div>
<h2 className="text-xl font-semibold text-gray-900">Vote Now</h2>
</div>
<AnimatePresence>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-5"
>
{upcomingItems.map((item, index) => (
<motion.div key={index} variants={itemVariants} layout>
<Card className="bg-white border border-gray-200 hover:border-primary-200 shadow-sm hover:shadow-md transition-all h-full flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="text-lg font-semibold text-gray-900 flex items-start">
{item.title}
</CardTitle>
</CardHeader>
<CardContent className="flex-grow">
<p className="text-gray-600 text-sm mb-4">
{item.description}
</p>
<div className="flex items-center justify-between border-t border-gray-100 pt-3">
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => handleVote(index)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
item.voted
? 'bg-primary-500 text-white hover:bg-primary-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<motion.span
animate={{ scale: item.voted ? [1, 1.2, 1] : 1 }}
transition={{ duration: 0.2 }}
>
<BiUpvote
className={`w-4 h-4 ${
item.voted ? 'text-white' : 'text-gray-600'
}`}
/>
</motion.span>
<span>{item.voted ? 'Voted' : 'Vote'}</span>
</motion.button>
<div className="flex items-center gap-2 bg-gray-50 px-3 py-1.5 rounded-lg">
<BiUpvote className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">
{item.votes}
</span>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
{/* In Progress Column */}
<div className="space-y-6">
<div className="flex items-center gap-3 px-2">
<div className="flex items-center justify-center w-10 h-10 bg-primary-100 text-primary-600 rounded-lg">
🧑🔧
</div>
<h2 className="text-xl font-semibold text-gray-900">In Progress</h2>
</div>
<AnimatePresence>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-5"
>
{inProgressItems.map((item, index) => (
<motion.div key={index} variants={itemVariants} layout>
<Card className="bg-white border border-gray-200 hover:border-primary-200 shadow-sm hover:shadow-md transition-all h-full">
<CardHeader className="pb-3">
<CardTitle className="text-lg font-semibold text-gray-900 flex items-start">
{item.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm">{item.description}</p>
<div className="mt-4 pt-3 border-t border-gray-100">
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-primary-500 h-1.5 rounded-full"
style={{ width: `${(index + 1) * 33}%` }}
></div>
</div>
<p className="text-xs text-gray-500 mt-2">
{index === 0 ? 'Development started' : 'In development'}
</p>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
{/* Completed Column */}
<div className="space-y-6">
<div className="flex items-center gap-3 px-2">
<div className="flex items-center justify-center w-10 h-10 bg-green-100 text-green-600 rounded-lg">
🤓
</div>
<h2 className="text-xl font-semibold text-gray-900">Completed</h2>
</div>
<AnimatePresence>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-5"
>
{completedItems.map((item, index) => (
<motion.div key={index} variants={itemVariants} layout>
<Card className="bg-white border border-gray-200 hover:border-green-200 shadow-sm hover:shadow-md transition-all h-full">
<CardHeader className="pb-3">
<CardTitle className="text-lg font-semibold text-gray-900 flex items-start">
{item.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm mb-3">
{item.description}
</p>
<div className="flex justify-between">
<div className="flex items-center gap-2 text-green-600">
<FiCheckCircle className="w-4 h-4" />
<span className="text-sm font-medium">Implemented</span>
</div>
<Button size="sm" className="flex gap-x-2">
Coba sekarang
<MdOutlineOpenInNew className="size-4" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
@@ -0,0 +1,120 @@
'use client';
import * as React from 'react';
import { useMediaQuery } from '@/hooks/use-media-query';
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Input,
Label,
Textarea,
} from '@components';
import { cn } from '@utils';
export function RequestFeaturePopup() {
const [open, setOpen] = React.useState(false);
const isDesktop = useMediaQuery('(min-width: 768px)');
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="secondary">Request Fitur</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-2xl">Request Fitur</DialogTitle>
</DialogHeader>
<ProfileForm />
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="secondary">Request Fitur</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Request Fitur</DrawerTitle>
</DrawerHeader>
<ProfileForm className="px-4" />
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="bordered">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
interface ProfileFormProps extends React.ComponentProps<'form'> {
showFooterButton?: boolean;
}
function ProfileForm({
className,
showFooterButton = false,
}: ProfileFormProps) {
const [description, setDescription] = React.useState('');
const maxDescriptionLength = 10000;
return (
<form className={cn('grid items-start gap-8', className)}>
<div className="grid gap-4">
<Label htmlFor="namaFitur" className="font-medium">
Nama Fitur *
</Label>
<Input
id="namaFitur"
placeholder="Masukkan nama fitur yang diinginkan"
className="h-11"
/>
</div>
<div className="grid gap-4">
<div className="flex justify-between items-center">
<Label htmlFor="deskripsiFitur" className="font-medium">
Deskripsi Fitur *
</Label>
<span className="text-sm text-muted-foreground">
{description.length}/{maxDescriptionLength}
</span>
</div>
<Textarea
id="deskripsiFitur"
placeholder="Jelaskan fitur yang Anda inginkan secara detail"
maxLength={maxDescriptionLength}
rows={6}
className="min-h-[150px]"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-sm text-muted-foreground -mt-2">
Jelaskan manfaat dan cara kerja fitur yang Anda inginkan
</p>
</div>
{!showFooterButton && (
<Button type="submit" size="lg" className="mt-2">
Kirim Permintaan
</Button>
)}
</form>
);
}
@@ -0,0 +1,11 @@
import { FeatureRequestCTA } from './_components/feature-request-cta';
import ProjectsVote from './_components/projects-vote';
export default function Page() {
return (
<div className="pt-4 md:pt-6 pb-56 bg-gray-50 min-h-screen mx-4 lg:mx-0">
<FeatureRequestCTA />
<ProjectsVote />
</div>
);
}
@@ -0,0 +1,343 @@
'use client';
import { useMediaQuery } from '@/hooks/use-media-query';
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Input,
Label,
Textarea,
} from '@components';
import { cn } from '@utils';
import * as React from 'react';
import { useRef, useState } from 'react';
import { LuArrowLeft, LuUpload, LuUser, LuX } from 'react-icons/lu';
export function TestimonialPopup() {
const [open, setOpen] = useState(false);
const isDesktop = useMediaQuery('(min-width: 768px)');
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Tambah Testimoni</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle>Tulis Testimoni</DialogTitle>
</DialogHeader>
<TestimonialForm setOpen={setOpen} />
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button>Tambah Testimoni</Button>
</DrawerTrigger>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="pb-0">
<DrawerTitle>Tulis Testimoni</DrawerTitle>
</DrawerHeader>
<div className="overflow-y-auto px-4">
<TestimonialForm setOpen={setOpen} className="py-2" />
</div>
<DrawerFooter className="pt-2 pb-4">
<DrawerClose asChild>
<Button variant="bordered">Batal</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
interface TestimonialFormProps extends React.ComponentProps<'div'> {
setOpen: (open: boolean) => void;
}
function TestimonialForm({ className, setOpen }: TestimonialFormProps) {
const [step, setStep] = useState(1);
const [testimonialError, setTestimonialError] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
email: '',
role: '',
testimonial: '',
});
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
alert('Hanya file gambar yang diizinkan');
return;
}
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setAvatarPreview(event.target.result as string);
}
};
reader.readAsDataURL(file);
}
};
const removeAvatar = (e: React.MouseEvent) => {
e.stopPropagation();
setAvatarPreview(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const triggerFileInput = () => {
fileInputRef.current?.click();
};
const validateStep1 = () => {
return formData.name.trim() !== '' && formData.email.trim() !== '';
};
const handleNextStep = () => {
if (validateStep1()) setStep(2);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setTestimonialError(false);
if (formData.testimonial.trim() === '') {
setTestimonialError(true);
return;
}
console.log('Testimonial submitted:', formData);
setOpen(false);
resetForm();
};
const resetForm = () => {
setStep(1);
setTestimonialError(false);
setAvatarPreview(null);
setFormData({
name: '',
email: '',
role: '',
testimonial: '',
});
if (fileInputRef.current) fileInputRef.current.value = '';
};
return (
<div className={cn('w-full', className)}>
<form onSubmit={handleSubmit}>
<div className="relative">
{step === 1 && (
<div className="w-full">
{/* Section header with step info aligned using justify-between */}
<div className="flex justify-between items-center pb-3 border-b mb-4">
<h3 className="text-base font-semibold">Lengkapi Data Diri</h3>
<span className="text-sm text-muted-foreground">
Langkah 1 dari 2
</span>
</div>
<div className="space-y-6">
<div className="flex flex-col items-center gap-3 mb-4">
<div className="relative">
<Avatar className="w-20 h-20 border-2 border-dashed rounded-full">
{avatarPreview ? (
<AvatarImage
src={avatarPreview}
alt="Preview"
className="object-cover"
/>
) : (
<AvatarFallback className="bg-secondary">
<LuUser className="w-8 h-8 text-muted-foreground" />
</AvatarFallback>
)}
</Avatar>
{avatarPreview && (
<button
type="button"
onClick={removeAvatar}
className="absolute -top-1 -right-1 bg-background rounded-full p-1 shadow-md border"
aria-label="Hapus foto"
>
<LuX className="w-3 h-3" />
</button>
)}
</div>
<div className="text-center">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileChange}
aria-label="Unggah foto"
/>
<Button
type="button"
variant="bordered"
className="border-dashed h-8 px-3 text-xs"
onClick={triggerFileInput}
>
<LuUpload className="w-3 h-3 mr-1" />
{avatarPreview ? 'Ganti Foto' : 'Unggah Foto'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
Maks. 2MB (JPEG, PNG)
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4">
{[
{
id: 'name',
label: 'Nama',
placeholder: 'Nama lengkap',
required: true,
},
{
id: 'email',
label: 'Email',
placeholder: 'email@contoh.com',
type: 'email',
required: true,
},
{
id: 'role',
label: 'Role',
placeholder: 'Backend Developer',
required: true,
},
].map((field) => (
<div key={field.id} className="space-y-1">
<Label htmlFor={field.id} className="text-sm">
{field.label}
</Label>
<Input
id={field.id}
type={field.type || 'text'}
placeholder={field.placeholder}
value={formData[field.id as keyof typeof formData]}
onChange={(e) =>
handleInputChange(field.id, e.target.value)
}
required={field.required}
className="py-2"
/>
</div>
))}
</div>
<div className="pt-2">
<Button
type="button"
size="lg"
className="w-full"
onClick={handleNextStep}
disabled={!validateStep1()}
>
Lanjut
</Button>
</div>
</div>
</div>
)}
{step === 2 && (
<div className="w-full">
{/* Section header with step info aligned using justify-between */}
<div className="flex justify-between items-center pb-3 border-b mb-4">
<h3 className="text-base font-semibold">Ulasan</h3>
<span className="text-sm text-muted-foreground">
Langkah 2 dari 2
</span>
</div>
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label htmlFor="testimonial" className="text-sm">
Testimoni
</Label>
<span className="text-xs text-muted-foreground">
{formData.testimonial.length}/500
</span>
</div>
<Textarea
id="testimonial"
placeholder="Bagaimana pengalaman Anda bersama IMPHNEN?"
className={cn(
'min-h-[140px]',
testimonialError && 'border-destructive'
)}
value={formData.testimonial}
onChange={(e) =>
handleInputChange('testimonial', e.target.value)
}
maxLength={500}
/>
{testimonialError && (
<div className="text-destructive text-xs mt-1">
Harap isi testimonial
</div>
)}
</div>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="bordered"
className="flex-1"
onClick={() => setStep(1)}
>
<LuArrowLeft className="w-4 h-4 mr-1" />
Kembali
</Button>
<Button type="submit" size="lg" className="flex-1">
Kirim
</Button>
</div>
</div>
</div>
)}
</div>
</form>
</div>
);
}
@@ -1,23 +1,20 @@
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';
import { TestimonialPopup } from './_components/testimonials-popup';
export default function Page() {
return (
<div className="min-h-screen">
<div className="py-16 px-4 text-center border-b border-border">
<div className="py-6 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">
<p className="max-w-2xl mx-auto text-lg mb-4 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>
<TestimonialPopup />
</div>
<div className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 my-16 container">
+2 -2
View File
@@ -1,4 +1,4 @@
import { poppinsFont } from '@/lib/fonts';
import { baiJamjureeFont } from '@/lib/fonts';
import '@/styles/globals.css';
import { cn } from '@utils';
import { type Metadata } from 'next';
@@ -17,7 +17,7 @@ export default function RootLayout({
}) {
return (
<html lang="id" suppressHydrationWarning>
<body className={cn(poppinsFont.className, 'antialiased')}>
<body className={cn(baiJamjureeFont.className, 'antialiased')}>
<Providers
attribute="class"
defaultTheme="system"
@@ -1,6 +0,0 @@
[
{ "value": "100K+", "label": "Member Aktif" },
{ "value": "50+", "label": "Event Bulanan" },
{ "value": "100+", "label": "Mentor Profesional" },
{ "value": "5K+", "label": "Diskusi Mingguan" }
]
-23
View File
@@ -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"
}
]
-22
View File
@@ -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."
}
]
@@ -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": "#"
}
]
+8
View File
@@ -10,5 +10,13 @@
{
"title": "Testimoni",
"link": "/testimonials"
},
{
"title": "Roadmap",
"link": "/roadmap"
},
{
"title": "Team",
"link": "/teams"
}
]
@@ -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" }
]
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean {
// 1) Always default to `false`—this guarantees SSR and the very first client render match.
const [matches, setMatches] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQueryList = window.matchMedia(query);
// 2) On mount, immediately set to the real match value (true or false).
setMatches(mediaQueryList.matches);
// 3) Subscribe to changes.
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQueryList.addEventListener('change', listener);
return () => {
mediaQueryList.removeEventListener('change', listener);
};
}, [query]);
return matches;
}
export default useMediaQuery;
+11
View File
@@ -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;
}
+13 -1
View File
@@ -3,6 +3,7 @@ import {
TLoginRequest,
TLoginResponse,
TRegisterRequest,
TSendOTPRequest,
TVerifyEmailRequest,
} from '../../types/auth';
import { TResponseMessage } from '../../types/common';
@@ -35,7 +36,18 @@ export const postVerifyEmail = async (
const { data } = await api({
method: 'POST',
url: '/auth/verify-email',
data: { otp: parseInt(payload.otp), email: payload.email },
data: {otp: parseInt(payload.otp), email: payload.email},
});
return data;
};
export const postSendOtp = async (
payload: TSendOTPRequest
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'POST',
url: '/auth/send-otp',
data: payload,
});
return data;
};
+15 -1
View File
@@ -1,9 +1,10 @@
import { useMutation, UseMutationResult } from '@tanstack/react-query';
import { postLogin, postRegister, postVerifyEmail } from '../../api/auth';
import { postLogin, postRegister, postSendOtp, postVerifyEmail } from '../../api/auth';
import {
TLoginRequest,
TLoginResponse,
TRegisterRequest,
TSendOTPRequest,
TVerifyEmailRequest,
} from '../../types/auth';
@@ -44,3 +45,16 @@ export const usePostVerifyEmail = (): UseMutationResult<
mutationFn: async (payload) => await postVerifyEmail(payload),
});
};
export const usePostSendOTP = (): UseMutationResult<
TResponseMessage,
TResponseError,
TSendOTPRequest,
unknown
> => {
return useMutation({
mutationKey: ['post-send-otp'],
mutationFn: async (payload) => await postSendOtp(payload),
});
};
+4
View File
@@ -27,3 +27,7 @@ export type TVerifyEmailRequest = {
email: string;
otp: string;
};
export type TSendOTPRequest = {
email: string
};
+489
View File
@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.4",
@@ -19,6 +20,7 @@
"react-icons": "^5.5.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4",
"vaul": "^1.1.2",
"zod": "^3.25.28"
},
"devDependencies": {
@@ -462,6 +464,39 @@
"react-hook-form": "^7.55.0"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-avatar": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
"integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-is-hydrated": "0.1.0",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -477,6 +512,142 @@
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.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-dismissable-layer": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"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-focus-guards": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
"integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"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-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"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",
@@ -500,6 +671,54 @@
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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",
@@ -541,6 +760,109 @@
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-is-hydrated": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz",
@@ -1088,6 +1410,18 @@
"license": "MIT",
"peer": true
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -1118,6 +1452,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@@ -1202,6 +1542,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1570,6 +1919,75 @@
"react": "*"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.0.tgz",
"integrity": "sha512-sGsQtcjMqdQyijAHytfGEELB8FufGbfXIsvUTe+NLx1GDRJCXtCFLBLUI1eyZCKXXvbEU2C6gai0PZKoIE9Vbg==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/rollup": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz",
@@ -1669,6 +2087,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tw-animate-css": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.8.tgz",
@@ -1679,6 +2103,71 @@
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/vite": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz",
+2
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.4",
@@ -21,6 +22,7 @@
"react-icons": "^5.5.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4",
"vaul": "^1.1.2",
"zod": "^3.25.28"
},
"devDependencies": {
+53
View File
@@ -0,0 +1,53 @@
'use client';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from 'react';
import { cn } from '../lib';
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className
)}
{...props}
/>
);
}
export { Avatar, AvatarFallback, AvatarImage };
+142
View File
@@ -0,0 +1,142 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import * as React from 'react';
import { LuX } from 'react-icons/lu';
import { cn } from '../lib';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<LuX />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+131
View File
@@ -0,0 +1,131 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '../lib';
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
};
-37
View File
@@ -1,37 +0,0 @@
import {
LuArrowDown as ArrowDownIcon,
LuBookOpen as BookOpenIcon,
LuCode as CodeIcon,
LuFacebook as FacebookIcon,
LuInstagram as InstagramIcon,
LuLaptop as LaptopIcon,
LuMenu as MenuIcon,
LuMessageCircle as MessageCircleIcon,
LuMoon as MoonIcon,
LuQuote as QuoteIcon,
LuShare2 as Share2Icon,
LuSparkles as SparklesIcon,
LuSun as SunIcon,
LuUsers as UsersIcon,
LuVideo as VideoIcon,
LuX as XIcon,
} from 'react-icons/lu';
export {
ArrowDownIcon,
BookOpenIcon,
CodeIcon,
FacebookIcon,
InstagramIcon,
LaptopIcon,
MenuIcon,
MessageCircleIcon,
MoonIcon,
QuoteIcon,
Share2Icon,
SparklesIcon,
SunIcon,
UsersIcon,
VideoIcon,
XIcon,
};
+4 -1
View File
@@ -1,6 +1,9 @@
export * from './avatar';
export * from './button';
export * from './card';
export * from './dialog';
export * from './drawer';
export * from './form';
export * from './icons';
export * from './input';
export * from './label';
export * from './textarea';
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '../lib';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
);
}
export { Textarea };
+2 -1
View File
@@ -3,4 +3,5 @@ export * from './use-auth-store';
export * from './use-modal-login';
export * from './use-session';
export * from './use-register';
export * from './use-otp';
export * from './use-otp';
export * from './use-send-otp';
+25
View File
@@ -0,0 +1,25 @@
import { TSendOTPRequest, usePostSendOTP } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const useSendOTP = () => {
const { mutate, isPending } = usePostSendOTP();
const resendOTP = (payload: TSendOTPRequest) => {
mutate(payload, {
onSuccess: (data) => {
toast.success('Berhasil Mengirim Ulang OTP');
},
onError: (err) => {
toast.error(
err?.response?.data?.message ??
'Terjadi Kesalahan yang tidak diketahui'
);
},
});
};
return {
resendOTP,
isLoading: isPending,
};
};
+525 -4
View File
@@ -13,6 +13,8 @@
"@hookform/resolvers": "^5.0.1",
"@iconify/react": "^6.0.0",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.10",
"@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",
@@ -36,6 +38,7 @@
"sharp": "^0.34.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.0.2",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.5"
},
@@ -7590,6 +7593,39 @@
"dev": true,
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-avatar": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
"integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-is-hydrated": "0.1.0",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -7605,10 +7641,240 @@
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.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-dismissable-layer": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"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-focus-guards": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
"integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"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-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"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-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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"
@@ -7623,6 +7889,109 @@
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-is-hydrated": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rc-component/util": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.2.1.tgz",
@@ -10946,7 +11315,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"
@@ -13331,6 +13700,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -16816,6 +17197,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/detect-port": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz",
@@ -19542,6 +19929,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -28312,6 +28708,53 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.0.tgz",
"integrity": "sha512-sGsQtcjMqdQyijAHytfGEELB8FufGbfXIsvUTe+NLx1GDRJCXtCFLBLUI1eyZCKXXvbEU2C6gai0PZKoIE9Vbg==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.2.tgz",
@@ -28360,6 +28803,28 @@
"node": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -33043,6 +33508,49 @@
"requires-port": "^1.0.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
@@ -33156,6 +33664,19 @@
"node": ">= 0.8"
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/verdaccio": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/verdaccio/-/verdaccio-6.1.2.tgz",
+3
View File
@@ -29,6 +29,8 @@
"@hookform/resolvers": "^5.0.1",
"@iconify/react": "^6.0.0",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.10",
"@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",
@@ -52,6 +54,7 @@
"sharp": "^0.34.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.0.2",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.5"
},