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
This commit is contained in:
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
const completedItems = [
|
||||
{
|
||||
title: 'IMPHNEN List Event',
|
||||
description:
|
||||
'Koleksi daftar event dan kolaborasi seru yang bisa kamu ikuti',
|
||||
},
|
||||
{
|
||||
title: 'IMPHNEN Testimoni',
|
||||
description: 'Berikan testimonial gokil buat komunitas IMPHNEN',
|
||||
},
|
||||
{
|
||||
title: 'IMPHNEN Roadmap by Vote',
|
||||
description: 'Usulkan ide fitur seru dan ajak anggota lain buat voting',
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -13,10 +13,10 @@
|
||||
},
|
||||
{
|
||||
"title": "Roadmap",
|
||||
"link": "/roadmaps"
|
||||
"link": "/roadmap"
|
||||
},
|
||||
{
|
||||
"title": "Artikel",
|
||||
"link": "/articles"
|
||||
"title": "Team",
|
||||
"link": "/teams"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user