perbaiki navbar

This commit is contained in:
krizzn65 2026-02-15 19:48:24 +07:00
parent 8c5ef3e1cd
commit b90cdc07d7
14 changed files with 803 additions and 382 deletions

23
frontend/components.json Normal file
View File

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

10
frontend/jsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}

View File

@ -9,12 +9,18 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.13.5",
"framer-motion": "^10.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^10.18.0",
"lucide-react": "^0.564.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.8.1",
"tailwindcss": "^3.4.19"
"tailwind-merge": "^3.4.1",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@ -1862,6 +1868,27 @@
"node": ">= 6"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2948,6 +2975,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -2977,6 +3010,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.564.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.564.0.tgz",
"integrity": "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3800,6 +3842,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz",
"integrity": "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@ -3837,6 +3889,15 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -3952,6 +4013,21 @@
"punycode": "^2.1.0"
}
},
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -11,12 +11,18 @@
},
"dependencies": {
"axios": "^1.13.5",
"framer-motion": "^10.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^10.18.0",
"lucide-react": "^0.564.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.8.1",
"tailwindcss": "^3.4.19"
"tailwind-merge": "^3.4.1",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -13,32 +13,32 @@ const Hero = ({ heroData }) => {
const hero = heroData || defaultHero;
return (
<section
id="hero"
className="min-h-screen flex items-center bg-gradient-to-br from-gray-50 to-purple-50 dark:from-gray-900 dark:to-purple-900 relative overflow-hidden pt-16 transition-colors duration-300"
<section
id="hero"
className="min-h-screen flex items-center bg-soft-white relative overflow-hidden pt-16 transition-colors duration-300"
>
{/* Background Decorations */}
<div className="absolute inset-0 overflow-hidden">
<motion.div
className="absolute top-20 left-10 w-72 h-72 bg-accent/10 rounded-full blur-3xl"
animate={{
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute top-[-10%] left-[-5%] w-[500px] h-[500px] bg-primary-light/30 rounded-full blur-[100px]"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3]
opacity: [0.3, 0.5, 0.3]
}}
transition={{
duration: 8,
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut"
ease: "easeInOut"
}}
/>
<motion.div
className="absolute bottom-20 right-10 w-96 h-96 bg-primary/10 rounded-full blur-3xl"
animate={{
<motion.div
className="absolute bottom-[-10%] right-[-5%] w-[600px] h-[600px] bg-primary/10 rounded-full blur-[120px]"
animate={{
scale: [1, 1.3, 1],
opacity: [0.3, 0.4, 0.3]
opacity: [0.3, 0.4, 0.3]
}}
transition={{
duration: 10,
transition={{
duration: 10,
repeat: Infinity,
ease: "easeInOut",
delay: 2
@ -49,21 +49,21 @@ const Hero = ({ heroData }) => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
{/* Left Content */}
<motion.div
<motion.div
className="space-y-8"
variants={slideUp}
initial="hidden"
animate="visible"
transition={{ duration: 0.8 }}
>
<motion.div
<motion.div
className="space-y-4"
variants={fadeIn}
initial="hidden"
animate="visible"
transition={{ delay: 0.2 }}
>
<motion.h1
<motion.h1
className="text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-tight"
variants={slideUp}
initial="hidden"
@ -72,8 +72,8 @@ const Hero = ({ heroData }) => {
>
{hero.title}
</motion.h1>
<motion.h2
<motion.h2
className="text-xl md:text-2xl lg:text-3xl font-semibold text-primary dark:text-primary-light"
variants={slideUp}
initial="hidden"
@ -84,7 +84,7 @@ const Hero = ({ heroData }) => {
</motion.h2>
</motion.div>
<motion.p
<motion.p
className="text-lg text-gray-600 dark:text-gray-300 leading-relaxed max-w-xl"
variants={fadeIn}
initial="hidden"
@ -94,7 +94,7 @@ const Hero = ({ heroData }) => {
{hero.description}
</motion.p>
<motion.div
<motion.div
className="flex flex-col sm:flex-row gap-4 pt-4"
variants={slideUp}
initial="hidden"
@ -115,7 +115,7 @@ const Hero = ({ heroData }) => {
>
Butuh Bantuan Darurat
</motion.a>
<motion.div
variants={fadeIn}
initial="hidden"
@ -124,7 +124,7 @@ const Hero = ({ heroData }) => {
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Link
<Link
to="/artikel"
className="px-8 py-4 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 hover:shadow-lg hover:-translate-y-1 font-semibold text-center shadow-soft inline-block w-full sm:w-auto"
>
@ -134,7 +134,7 @@ const Hero = ({ heroData }) => {
</motion.div>
{/* Trust Indicators */}
<motion.div
<motion.div
className="flex flex-wrap gap-6 pt-8"
variants={fadeIn}
initial="hidden"
@ -145,12 +145,12 @@ const Hero = ({ heroData }) => {
<div className="w-2 h-2 bg-accent rounded-full"></div>
<span className="text-sm text-gray-600 dark:text-gray-300 font-medium">100% Rahasia</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-accent rounded-full"></div>
<span className="text-sm text-gray-600 dark:text-gray-300 font-medium">Profesional</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-accent rounded-full"></div>
<span className="text-sm text-gray-600 dark:text-gray-300 font-medium">24/7 Support</span>
@ -159,7 +159,7 @@ const Hero = ({ heroData }) => {
</motion.div>
{/* Right Content - Logo & Branding */}
<motion.div
<motion.div
className="relative lg:pl-12"
variants={slideRight}
initial="hidden"
@ -167,66 +167,24 @@ const Hero = ({ heroData }) => {
transition={{ duration: 0.8, delay: 0.3 }}
>
{/* Main Logo Container */}
<motion.div
className="relative z-10"
animate={{
y: [0, -20, 0],
}}
transition={{
duration: 6,
repeat: Infinity,
ease: "easeInOut"
}}
<motion.div
className="relative z-10 flex justify-end lg:pr-4"
>
<div className="bg-gradient-to-br from-primary/10 to-accent/10 dark:from-primary/20 dark:to-accent/20 rounded-3xl p-12 backdrop-blur-sm border border-white/50 dark:border-gray-700/50 shadow-soft">
<div className="aspect-square max-w-md mx-auto flex flex-col items-center justify-center space-y-6">
{/* Logo Image */}
<motion.div
animate={{
scale: [1, 1.05, 1],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: "easeInOut"
}}
className="relative"
>
<div className="w-48 h-48 bg-white dark:bg-gray-800 rounded-2xl shadow-lg flex items-center justify-center p-6">
<img
src="/logo_polijecare.png"
alt="Polijecare Logo"
className="w-full h-full object-contain"
/>
</div>
{/* Glow Effect */}
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 rounded-2xl blur-xl -z-10"></div>
</motion.div>
{/* Brand Text */}
<motion.div
className="text-center space-y-2"
variants={fadeIn}
initial="hidden"
animate="visible"
transition={{ delay: 0.5 }}
>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">Polijecare</h3>
<p className="text-primary dark:text-primary-light font-medium">Satgas PPKPT Polije</p>
<div className="w-16 h-1 bg-gradient-to-r from-primary to-accent rounded-full mx-auto"></div>
</motion.div>
</div>
</div>
<img
src="/gambar_header.png"
alt="header gambar"
className="w-full max-w-[500px] h-auto object-cover"
/>
</motion.div>
{/* Background Shape */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-100/50 dark:from-purple-900/20 to-accent/10 dark:to-accent/20 rounded-3xl blur-2xl -z-10"></div>
{/* Background Shape */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-100/50 dark:from-purple-900/20 to-accent/10 dark:to-accent/20 rounded-3xl blur-2xl -z-10"></div>
</motion.div>
</div>
</div>
{/* Scroll Indicator */}
<motion.div
<motion.div
className="absolute bottom-8 left-1/2 transform -translate-x-1/2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}

View File

@ -1,4 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Home, Info, FileText, BookOpen, Phone } from 'lucide-react';
import { ExpandableTabs } from "@/components/ui/expandable-tabs";
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../hooks/useAuth';
@ -6,8 +8,10 @@ import { fadeIn, slideDown } from '../utils/motionVariants';
import ThemeToggle from './ThemeToggle';
import LoginModal from './LoginModal';
const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
const [activeLink, setActiveLink] = useState('#hero');
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const { isAuthenticated, user, logout } = useAuth();
@ -22,14 +26,56 @@ const Navbar = () => {
{ name: 'Kontak', href: '#contact' }
];
const tabs = [
{ title: "Beranda", icon: Home },
{ title: "Tentang Kami", icon: Info },
{ title: "Cara Melapor", icon: FileText },
{ title: "Artikel", icon: BookOpen },
{ title: "Kontak", icon: Phone },
];
const handleNavClick = (href) => {
if (href.startsWith('#')) {
const element = document.querySelector(href);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
setIsMobileMenuOpen(false);
};
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
// Logic for switching navbar type (Standard vs Expandable)
// Switch when approaching 'about' section or scrolled past 1st screen
const aboutSection = document.getElementById('about');
const threshold = aboutSection ? aboutSection.offsetTop - 400 : window.innerHeight - 200;
setIsScrolled(window.scrollY > threshold);
// ScrollSpy Logic
const sections = navLinks.map(link => link.href.substring(1));
let currentSection = "";
for (const section of sections) {
const element = document.getElementById(section);
if (element) {
const rect = element.getBoundingClientRect();
if (rect.top <= 150 && rect.bottom >= 150) {
currentSection = "#" + section;
}
}
}
if (currentSection && currentSection !== activeLink) {
setActiveLink(currentSection);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
}, [navLinks, activeLink]);
const handleLogout = () => {
logout();
@ -38,7 +84,7 @@ const Navbar = () => {
const handleDashboardRedirect = () => {
if (!user) return;
switch (user.role) {
case 'user':
navigate('/user/dashboard');
@ -55,226 +101,248 @@ const Navbar = () => {
}
};
const handleNavClick = (href) => {
if (href.startsWith('#')) {
const element = document.querySelector(href);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
setIsMobileMenuOpen(false);
};
return (
<>
<motion.nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
isScrolled
? 'bg-white/95 backdrop-blur-md shadow-soft border-b border-gray-100'
: 'bg-white shadow-sm'
}`}
variants={fadeIn}
initial="hidden"
animate="visible"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-primary to-accent rounded-xl flex items-center justify-center shadow-lg">
<img
src="/logo_polijecare.png"
alt="Polijecare Logo"
className="w-8 h-8 object-contain"
/>
</div>
<span className="text-xl font-bold text-gray-900 dark:text-white">
Polijecare
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8">
<motion.div
className="flex space-x-6"
variants={slideDown}
initial="hidden"
animate="visible"
>
{navLinks.map((link, index) => (
<motion.div
key={link.name}
variants={fadeIn}
transition={{ delay: 0.1 * index }}
>
{link.href.startsWith('#') ? (
<button
onClick={() => handleNavClick(link.href)}
className="text-gray-600 hover:text-primary font-medium transition-colors relative group"
>
{link.name}
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
</button>
) : (
<Link
to={link.href}
className="text-gray-600 hover:text-primary font-medium transition-colors relative group"
>
{link.name}
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
</Link>
)}
</motion.div>
))}
</motion.div>
{/* Auth Buttons & Theme Toggle */}
<div className="flex items-center space-x-4">
{isAuthenticated ? (
<>
<button
onClick={handleDashboardRedirect}
className="px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 hover:shadow-soft font-medium"
>
Dashboard
</button>
<button
onClick={handleLogout}
className="px-4 py-2 border-2 border-primary text-primary rounded-xl hover:bg-primary hover:text-white transition-all duration-300 font-medium"
>
Keluar
</button>
</>
) : (
<button
onClick={() => setIsLoginModalOpen(true)}
className="px-6 py-2.5 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 hover:shadow-soft font-medium"
>
Masuk
</button>
)}
{/* Theme Toggle */}
<ThemeToggle />
</div>
</div>
{/* Mobile menu button */}
<div className="md:hidden">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="text-gray-600 hover:text-primary p-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<AnimatePresence mode="wait">
{isMobileMenuOpen ? (
<motion.path
key="close"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
initial={{ opacity: 0, rotate: -90 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: 90 }}
transition={{ duration: 0.2 }}
/>
) : (
<motion.path
key="menu"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</svg>
</button>
</div>
</div>
</div>
{/* Mobile menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
className="md:hidden absolute top-full left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<div className="px-4 py-6 space-y-4">
{navLinks.map((link, index) => (
<motion.div
key={link.name}
variants={fadeIn}
initial="hidden"
animate="visible"
transition={{ delay: 0.05 * index }}
>
{link.href.startsWith('#') ? (
<button
onClick={() => handleNavClick(link.href)}
className="block w-full text-left px-4 py-3 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 font-medium"
>
{link.name}
</button>
) : (
<Link
to={link.href}
onClick={() => setIsMobileMenuOpen(false)}
className="block w-full px-4 py-3 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 font-medium"
>
{link.name}
</Link>
)}
</motion.div>
))}
<div className="pt-4 border-t border-gray-100 dark:border-gray-700 space-y-3">
<AnimatePresence>
{isScrolled ? (
<motion.div
key="expandable-tabs"
initial={{ opacity: 0, scale: 0.8, y: -20, x: "-50%" }}
animate={{ opacity: 1, scale: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, scale: 0.8, y: -20, x: "-50%", transition: { duration: 0.1 } }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="fixed top-4 left-1/2 z-50 transform -translate-x-1/2"
>
<ExpandableTabs
tabs={tabs}
activeTab={navLinks.findIndex(link => link.href === activeLink)}
onChange={(index) => {
if (index !== null) {
const href = navLinks[index].href;
setActiveLink(href);
handleNavClick(href);
}
}}
trailingElement={
<div className="flex items-center gap-2">
<ThemeToggle />
{isAuthenticated ? (
<>
<button
onClick={handleDashboardRedirect}
className="w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium"
className="px-4 py-2 text-sm font-medium bg-[#191970] text-white rounded-full hover:bg-blue-900 transition-colors"
>
Dashboard
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-3 border-2 border-primary text-primary rounded-xl hover:bg-primary hover:text-white transition-all duration-300 font-medium"
className="px-4 py-2 text-sm font-medium border border-[#191970] text-[#191970] rounded-full hover:bg-gray-100 transition-colors"
>
Keluar
</button>
</>
) : (
<button
onClick={() => {
setIsLoginModalOpen(true);
setIsMobileMenuOpen(false);
}}
className="w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium"
onClick={() => setIsLoginModalOpen(true)}
className="px-6 py-2 text-sm font-medium bg-[#191970] text-white rounded-full hover:bg-blue-900 transition-colors shadow-lg"
>
Masuk
</button>
)}
</div>
}
/>
</motion.div>
) : (
<motion.nav
key="navbar"
initial={{ opacity: 0, scale: 0.9, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -20, transition: { duration: 0.1 } }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="fixed top-0 left-0 right-0 z-50 transition-all duration-300 bg-white/50 backdrop-blur-md border-b border-white/20 shadow-sm"
>
<div className="w-full px-4">
<div className="flex items-center justify-between h-20">
{/* Logo Section - Left */}
<Link to="/" className="flex items-center space-x-2 cursor-default">
<img
src="/logo_polije.png"
alt="Logo Polije"
className="h-12 w-auto object-contain"
/>
<img
src="/logo_polijecare.png"
alt="Polijecare Logo"
className="h-12 w-auto object-contain"
/>
</Link>
{/* Centered Navigation Links */}
<div className="hidden md:flex items-center space-x-1">
{navLinks.map((link) => (
<button
key={link.name}
onClick={() => {
setActiveLink(link.href);
handleNavClick(link.href);
}}
className={`px-5 py-2.5 text-base font-medium rounded-full transition-all duration-200 ${activeLink === link.href
? 'bg-[#191970] text-white shadow-[0_4px_15px_rgba(25,25,112,0.4)]'
: 'text-gray-600 hover:text-[#191970] hover:bg-gray-100'
}`}
>
{link.name}
</button>
))}
</div>
{/* Right Section - Auth & Theme */}
<div className="hidden md:flex items-center space-x-4">
<ThemeToggle />
{isAuthenticated ? (
<>
<button
onClick={handleDashboardRedirect}
className="px-5 py-2.5 bg-primary text-white rounded-full hover:bg-primary-dark transition-all duration-300 hover:shadow-lg font-medium text-sm"
>
Dashboard
</button>
<button
onClick={handleLogout}
className="px-5 py-2.5 border-2 border-primary text-primary rounded-full hover:bg-primary hover:text-white transition-all duration-300 font-medium text-sm"
>
Keluar
</button>
</>
) : (
<button
onClick={() => setIsLoginModalOpen(true)}
className="px-6 py-2.5 bg-primary text-white rounded-full hover:bg-primary-dark transition-all duration-300 hover:shadow-lg font-medium text-sm"
>
Masuk
</button>
)}
</div>
{/* Mobile menu button */}
<div className="md:hidden">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="text-gray-600 hover:text-primary p-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<AnimatePresence mode="wait">
{isMobileMenuOpen ? (
<motion.path
key="close"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
initial={{ opacity: 0, rotate: -90 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: 90 }}
transition={{ duration: 0.2 }}
/>
) : (
<motion.path
key="menu"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</svg>
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.nav>
{/* Login Modal */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
</div>
{/* Mobile menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
className="md:hidden absolute top-full left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<div className="px-4 py-6 space-y-4">
{navLinks.map((link, index) => (
<motion.div
key={link.name}
variants={fadeIn}
initial="hidden"
animate="visible"
transition={{ delay: 0.05 * index }}
>
{link.href.startsWith('#') ? (
<button
onClick={() => handleNavClick(link.href)}
className="block w-full text-left px-4 py-3 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 font-medium"
>
{link.name}
</button>
) : (
<Link
to={link.href}
onClick={() => setIsMobileMenuOpen(false)}
className="block w-full px-4 py-3 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 font-medium"
>
{link.name}
</Link>
)}
</motion.div>
))}
<div className="pt-4 border-t border-gray-100 dark:border-gray-700 space-y-3">
{isAuthenticated ? (
<>
<button
onClick={handleDashboardRedirect}
className="w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium"
>
Dashboard
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-3 border-2 border-primary text-primary rounded-xl hover:bg-primary hover:text-white transition-all duration-300 font-medium"
>
Keluar
</button>
</>
) : (
<button
onClick={() => {
setIsLoginModalOpen(true);
setIsMobileMenuOpen(false);
}}
className="w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium"
>
Masuk
</button>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.nav>
)}
</AnimatePresence>
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
/>
</>
);

View File

@ -0,0 +1,115 @@
"use client";;
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useOnClickOutside } from "usehooks-ts";
import { cn } from "@/lib/utils";
const buttonVariants = {
initial: {
gap: 0,
paddingLeft: ".5rem",
paddingRight: ".5rem",
},
animate: (isSelected) => ({
gap: isSelected ? ".5rem" : 0,
paddingLeft: isSelected ? "2rem" : "1rem",
paddingRight: isSelected ? "2rem" : "1rem",
}),
};
const spanVariants = {
initial: { width: 0, opacity: 0 },
animate: { width: "auto", opacity: 1 },
exit: { width: 0, opacity: 0 },
};
const transition = { type: "spring", bounce: 0, duration: 0.3 };
export function ExpandableTabs({
tabs,
className,
activeColor = "text-primary",
onChange,
activeTab,
trailingElement
}) {
const [selected, setSelected] = React.useState(null);
const outsideClickRef = React.useRef(null);
React.useEffect(() => {
if (activeTab !== undefined && activeTab !== null) {
setSelected(activeTab);
}
}, [activeTab]);
useOnClickOutside(outsideClickRef, () => {
setSelected(null);
onChange?.(null);
});
const handleSelect = (index) => {
setSelected(index);
onChange?.(index);
};
const Separator = () => (
<div className="mx-1 h-[24px] w-[1.2px] bg-border" aria-hidden="true" />
);
return (
<div
ref={outsideClickRef}
className={cn(
"flex flex-wrap items-center gap-2 rounded-full border border-white/20 bg-white/80 backdrop-blur-md p-1 shadow-lg",
className
)}>
{tabs.map((tab, index) => {
if (tab.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
const Icon = tab.icon;
return (
<motion.button
key={tab.title}
layout
variants={buttonVariants}
initial={false}
animate="animate"
custom={selected === index}
onClick={() => handleSelect(index)}
transition={transition}
className={cn(
"relative flex items-center rounded-full py-3 text-base font-medium transition-colors duration-300",
selected === index
? "bg-[#191970] text-white shadow-[0_4px_15px_rgba(25,25,112,0.4)]"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}>
<Icon size={24} />
<AnimatePresence initial={false}>
{selected === index && (
<motion.span
variants={spanVariants}
initial="initial"
animate="animate"
exit="exit"
transition={transition}
className="overflow-hidden whitespace-nowrap">
{tab.title}
</motion.span>
)}
</AnimatePresence>
</motion.button>
);
})}
{trailingElement && (
<>
<Separator />
<div className="pl-1">
{trailingElement}
</div>
</>
)}
</div>
);
}

View File

@ -1,4 +1,4 @@
/* Tailwind CSS Directives - v3.4.1 */
/* Tailwind CSS Directives - v3.4.1 */
/* eslint-disable-next-line at-rule-no-unknown */
@tailwind base;
/* eslint-disable-next-line at-rule-no-unknown */
@ -8,16 +8,25 @@
/* Custom CSS Variables and Overrides */
:root {
--color-primary: #E6E6FA; /* Lavender */
--color-primary-dark: #D8BFD8; /* Thistle */
--color-accent: #4C6EF5; /* Professional Blue */
--color-primary: #e6e6fa;
/* Refined Lavender/Purple for better visibility */
--color-primary-light: #E9D5FF;
/* Soft Lavender for backgrounds */
--color-primary-dark: #805AD5;
--color-accent: #4C6EF5;
/* Professional Blue */
--color-accent-light: #5C7CFA;
--color-secondary: #495057; /* Professional Gray */
--color-secondary: #495057;
/* Professional Gray */
--color-secondary-light: #6C757D;
--color-danger: #dc2626;
--color-danger-light: #ef4444;
--color-white: #ffffff;
--color-gray-50: #f9fafb;
--color-soft-white: #F8F9FA;
/* Soft White Foundation */
--color-gray-50: #F8F9FA;
/* Matches soft white */
--color-gray-100: #f3f4f6;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
@ -31,13 +40,16 @@
/* Dark Mode Variables */
.dark {
--color-primary: #D8BFD8; /* Darker Lavender */
--color-primary: #D8BFD8;
/* Darker Lavender */
--color-primary-dark: #C8B2DB;
--color-accent: #5C7CFA; /* Lighter Blue for dark mode */
--color-accent: #5C7CFA;
/* Lighter Blue for dark mode */
--color-accent-light: #7C8FFA;
--color-secondary: #6C757D;
--color-secondary-light: #868E96;
--color-white: #1f2937; /* Dark background */
--color-white: #1f2937;
/* Dark background */
--color-gray-50: #374151;
--color-gray-100: #4b5563;
--color-gray-200: #6b7280;
@ -353,18 +365,40 @@ body {
background-color: var(--color-gray-50);
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
line-height: 1.2;
color: var(--color-gray-900);
}
h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.5rem; }
h4 { font-size: 1.25rem; }
h5 { font-size: 1.125rem; }
h6 { font-size: 1rem; }
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.5rem;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1.125rem;
}
h6 {
font-size: 1rem;
}
p {
margin-bottom: 1rem;
@ -380,6 +414,61 @@ body {
a:hover {
color: var(--color-primary-dark);
}
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
/* Tailwind CSS Components Layer */
@ -451,20 +540,39 @@ body {
}
@media (max-width: 768px) {
h1 { font-size: 2rem; }
h2 { font-size: 1.75rem; }
h3 { font-size: 1.25rem; }
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.75rem;
}
h3 {
font-size: 1.25rem;
}
/* tailwindcss@3.4.1 - Mobile Responsive Overrides */
.container {
@apply px-3;
}
.btn {
@apply px-5 py-2.5 text-sm;
}
.card {
@apply p-6;
}
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
}

View File

@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View File

@ -4,77 +4,119 @@ export default {
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
darkMode: ['class', "class"],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#E6E6FA',
dark: '#D8BFD8',
light: '#F0E6FF',
},
accent: {
DEFAULT: '#4C6EF5',
light: '#5C7CFA',
dark: '#364FC7',
},
secondary: {
DEFAULT: '#495057',
light: '#6C757D',
dark: '#343A40',
},
danger: {
DEFAULT: '#DC2626',
light: '#EF4444',
dark: '#B91C1C',
},
gray: {
50: '#F5F7FA',
100: '#E5E7EB',
200: '#D1D5DB',
300: '#9CA3AF',
400: '#6B7280',
500: '#4B5563',
600: '#374151',
700: '#1F2937',
800: '#111827',
900: '#030712',
},
purple: {
50: '#F3E8FF',
100: '#E9D5FF',
200: '#D8B4FE',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
spacing: {
'18': '4.5rem',
'80': '20rem',
'88': '22rem',
},
borderRadius: {
'xl': '0.75rem',
'2xl': '1rem',
'3xl': '1.5rem',
},
boxShadow: {
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
'card': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'card-hover': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
},
animation: {
'float': 'float 3s ease-in-out infinite',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-10px)' },
},
},
},
extend: {
colors: {
primary: {
DEFAULT: 'hsl(var(--primary))',
dark: '#D8BFD8',
light: '#F0E6FF',
foreground: 'hsl(var(--primary-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
light: '#5C7CFA',
dark: '#364FC7',
foreground: 'hsl(var(--accent-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
light: '#6C757D',
dark: '#343A40',
foreground: 'hsl(var(--secondary-foreground))'
},
danger: {
DEFAULT: '#DC2626',
light: '#EF4444',
dark: '#B91C1C'
},
gray: {
'50': '#F5F7FA',
'100': '#E5E7EB',
'200': '#D1D5DB',
'300': '#9CA3AF',
'400': '#6B7280',
'500': '#4B5563',
'600': '#374151',
'700': '#1F2937',
'800': '#111827',
'900': '#030712'
},
purple: {
'50': '#F3E8FF',
'100': '#E9D5FF',
'200': '#D8B4FE'
},
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
fontFamily: {
sans: [
'Inter',
'system-ui',
'sans-serif'
]
},
spacing: {
'18': '4.5rem',
'80': '20rem',
'88': '22rem'
},
borderRadius: {
xl: '0.75rem',
'2xl': '1rem',
'3xl': '1.5rem',
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
boxShadow: {
soft: '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
card: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'card-hover': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'
},
animation: {
float: 'float 3s ease-in-out infinite',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite'
},
keyframes: {
float: {
'0%, 100%': {
transform: 'translateY(0px)'
},
'50%': {
transform: 'translateY(-10px)'
}
}
}
}
},
plugins: [],
plugins: [require("tailwindcss-animate")],
}

View File

@ -1,7 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from "path"
import { fileURLToPath } from "url"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})