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", "version": "0.0.0",
"dependencies": { "dependencies": {
"axios": "^1.13.5", "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": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.8.1", "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": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@ -1862,6 +1868,27 @@
"node": ">= 6" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2948,6 +2975,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -2977,6 +3010,15 @@
"yallist": "^3.0.2" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3800,6 +3842,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tailwindcss": {
"version": "3.4.19", "version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@ -3837,6 +3889,15 @@
"node": ">=14.0.0" "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": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -3952,6 +4013,21 @@
"punycode": "^2.1.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -11,12 +11,18 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.5", "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": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.8.1", "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": { "devDependencies": {
"@eslint/js": "^9.39.1", "@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

@ -15,12 +15,12 @@ const Hero = ({ heroData }) => {
return ( return (
<section <section
id="hero" 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" className="min-h-screen flex items-center bg-soft-white relative overflow-hidden pt-16 transition-colors duration-300"
> >
{/* Background Decorations */} {/* Background Decorations */}
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div <motion.div
className="absolute top-20 left-10 w-72 h-72 bg-accent/10 rounded-full blur-3xl" className="absolute top-[-10%] left-[-5%] w-[500px] h-[500px] bg-primary-light/30 rounded-full blur-[100px]"
animate={{ animate={{
scale: [1, 1.2, 1], scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3] opacity: [0.3, 0.5, 0.3]
@ -32,7 +32,7 @@ const Hero = ({ heroData }) => {
}} }}
/> />
<motion.div <motion.div
className="absolute bottom-20 right-10 w-96 h-96 bg-primary/10 rounded-full blur-3xl" className="absolute bottom-[-10%] right-[-5%] w-[600px] h-[600px] bg-primary/10 rounded-full blur-[120px]"
animate={{ animate={{
scale: [1, 1.3, 1], scale: [1, 1.3, 1],
opacity: [0.3, 0.4, 0.3] opacity: [0.3, 0.4, 0.3]
@ -168,59 +168,17 @@ const Hero = ({ heroData }) => {
> >
{/* Main Logo Container */} {/* Main Logo Container */}
<motion.div <motion.div
className="relative z-10" className="relative z-10 flex justify-end lg:pr-4"
animate={{
y: [0, -20, 0],
}}
transition={{
duration: 6,
repeat: Infinity,
ease: "easeInOut"
}}
> >
<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"> <img
<div className="aspect-square max-w-md mx-auto flex flex-col items-center justify-center space-y-6"> src="/gambar_header.png"
{/* Logo Image */} alt="header gambar"
<motion.div className="w-full max-w-[500px] h-auto object-cover"
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>
</motion.div> </motion.div>
{/* Background Shape */} {/* 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> <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> </motion.div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,6 @@
import React, { useState, useEffect } from 'react'; 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 { Link, useNavigate, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
@ -6,8 +8,10 @@ import { fadeIn, slideDown } from '../utils/motionVariants';
import ThemeToggle from './ThemeToggle'; import ThemeToggle from './ThemeToggle';
import LoginModal from './LoginModal'; import LoginModal from './LoginModal';
const Navbar = () => { const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [activeLink, setActiveLink] = useState('#hero');
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const { isAuthenticated, user, logout } = useAuth(); const { isAuthenticated, user, logout } = useAuth();
@ -22,14 +26,56 @@ const Navbar = () => {
{ name: 'Kontak', href: '#contact' } { 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(() => { useEffect(() => {
const handleScroll = () => { 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); window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, []); }, [navLinks, activeLink]);
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
@ -55,223 +101,245 @@ const Navbar = () => {
} }
}; };
const handleNavClick = (href) => {
if (href.startsWith('#')) {
const element = document.querySelector(href);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
setIsMobileMenuOpen(false);
};
return ( return (
<> <>
<motion.nav <AnimatePresence>
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${ {isScrolled ? (
isScrolled <motion.div
? 'bg-white/95 backdrop-blur-md shadow-soft border-b border-gray-100' key="expandable-tabs"
: 'bg-white shadow-sm' initial={{ opacity: 0, scale: 0.8, y: -20, x: "-50%" }}
}`} animate={{ opacity: 1, scale: 1, y: 0, x: "-50%" }}
variants={fadeIn} exit={{ opacity: 0, scale: 0.8, y: -20, x: "-50%", transition: { duration: 0.1 } }}
initial="hidden" transition={{ duration: 0.2, ease: "easeOut" }}
animate="visible" className="fixed top-4 left-1/2 z-50 transform -translate-x-1/2"
> >
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <ExpandableTabs
<div className="flex items-center justify-between h-16"> tabs={tabs}
{/* Logo */} activeTab={navLinks.findIndex(link => link.href === activeLink)}
<Link to="/" className="flex items-center space-x-3"> onChange={(index) => {
<div className="w-10 h-10 bg-gradient-to-br from-primary to-accent rounded-xl flex items-center justify-center shadow-lg"> if (index !== null) {
<img const href = navLinks[index].href;
src="/logo_polijecare.png" setActiveLink(href);
alt="Polijecare Logo" handleNavClick(href);
className="w-8 h-8 object-contain" }
/> }}
</div> trailingElement={
<span className="text-xl font-bold text-gray-900 dark:text-white"> <div className="flex items-center gap-2">
Polijecare <ThemeToggle />
</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">
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<button <button
onClick={handleDashboardRedirect} 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 Dashboard
</button> </button>
<button <button
onClick={handleLogout} 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 Keluar
</button> </button>
</> </>
) : ( ) : (
<button <button
onClick={() => { onClick={() => setIsLoginModalOpen(true)}
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"
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 Masuk
</button> </button>
)} )}
</div> </div>
</div> }
</motion.div> />
)} </motion.div>
</AnimatePresence> ) : (
</motion.nav> <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>
</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>
{/* Login Modal */}
<LoginModal <LoginModal
isOpen={isLoginModalOpen} isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)} 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 */ /* eslint-disable-next-line at-rule-no-unknown */
@tailwind base; @tailwind base;
/* eslint-disable-next-line at-rule-no-unknown */ /* eslint-disable-next-line at-rule-no-unknown */
@ -8,16 +8,25 @@
/* Custom CSS Variables and Overrides */ /* Custom CSS Variables and Overrides */
:root { :root {
--color-primary: #E6E6FA; /* Lavender */ --color-primary: #e6e6fa;
--color-primary-dark: #D8BFD8; /* Thistle */ /* Refined Lavender/Purple for better visibility */
--color-accent: #4C6EF5; /* Professional Blue */ --color-primary-light: #E9D5FF;
/* Soft Lavender for backgrounds */
--color-primary-dark: #805AD5;
--color-accent: #4C6EF5;
/* Professional Blue */
--color-accent-light: #5C7CFA; --color-accent-light: #5C7CFA;
--color-secondary: #495057; /* Professional Gray */ --color-secondary: #495057;
/* Professional Gray */
--color-secondary-light: #6C757D; --color-secondary-light: #6C757D;
--color-danger: #dc2626; --color-danger: #dc2626;
--color-danger-light: #ef4444; --color-danger-light: #ef4444;
--color-white: #ffffff; --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-100: #f3f4f6;
--color-gray-200: #e5e7eb; --color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db; --color-gray-300: #d1d5db;
@ -31,13 +40,16 @@
/* Dark Mode Variables */ /* Dark Mode Variables */
.dark { .dark {
--color-primary: #D8BFD8; /* Darker Lavender */ --color-primary: #D8BFD8;
/* Darker Lavender */
--color-primary-dark: #C8B2DB; --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-accent-light: #7C8FFA;
--color-secondary: #6C757D; --color-secondary: #6C757D;
--color-secondary-light: #868E96; --color-secondary-light: #868E96;
--color-white: #1f2937; /* Dark background */ --color-white: #1f2937;
/* Dark background */
--color-gray-50: #374151; --color-gray-50: #374151;
--color-gray-100: #4b5563; --color-gray-100: #4b5563;
--color-gray-200: #6b7280; --color-gray-200: #6b7280;
@ -353,18 +365,40 @@ body {
background-color: var(--color-gray-50); background-color: var(--color-gray-50);
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
color: var(--color-gray-900); color: var(--color-gray-900);
} }
h1 { font-size: 2.5rem; } h1 {
h2 { font-size: 2rem; } font-size: 2.5rem;
h3 { font-size: 1.5rem; } }
h4 { font-size: 1.25rem; }
h5 { font-size: 1.125rem; } h2 {
h6 { font-size: 1rem; } font-size: 2rem;
}
h3 {
font-size: 1.5rem;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1.125rem;
}
h6 {
font-size: 1rem;
}
p { p {
margin-bottom: 1rem; margin-bottom: 1rem;
@ -380,6 +414,61 @@ body {
a:hover { a:hover {
color: var(--color-primary-dark); 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 */ /* Tailwind CSS Components Layer */
@ -451,9 +540,17 @@ body {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
h1 { font-size: 2rem; } h1 {
h2 { font-size: 1.75rem; } font-size: 2rem;
h3 { font-size: 1.25rem; } }
h2 {
font-size: 1.75rem;
}
h3 {
font-size: 1.25rem;
}
/* tailwindcss@3.4.1 - Mobile Responsive Overrides */ /* tailwindcss@3.4.1 - Mobile Responsive Overrides */
.container { .container {
@ -468,3 +565,14 @@ body {
@apply p-6; @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", "./index.html",
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",
], ],
darkMode: 'class', darkMode: ['class', "class"],
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { primary: {
DEFAULT: '#E6E6FA', DEFAULT: 'hsl(var(--primary))',
dark: '#D8BFD8', dark: '#D8BFD8',
light: '#F0E6FF', light: '#F0E6FF',
}, foreground: 'hsl(var(--primary-foreground))'
accent: { },
DEFAULT: '#4C6EF5', accent: {
light: '#5C7CFA', DEFAULT: 'hsl(var(--accent))',
dark: '#364FC7', light: '#5C7CFA',
}, dark: '#364FC7',
secondary: { foreground: 'hsl(var(--accent-foreground))'
DEFAULT: '#495057', },
light: '#6C757D', secondary: {
dark: '#343A40', DEFAULT: 'hsl(var(--secondary))',
}, light: '#6C757D',
danger: { dark: '#343A40',
DEFAULT: '#DC2626', foreground: 'hsl(var(--secondary-foreground))'
light: '#EF4444', },
dark: '#B91C1C', danger: {
}, DEFAULT: '#DC2626',
gray: { light: '#EF4444',
50: '#F5F7FA', dark: '#B91C1C'
100: '#E5E7EB', },
200: '#D1D5DB', gray: {
300: '#9CA3AF', '50': '#F5F7FA',
400: '#6B7280', '100': '#E5E7EB',
500: '#4B5563', '200': '#D1D5DB',
600: '#374151', '300': '#9CA3AF',
700: '#1F2937', '400': '#6B7280',
800: '#111827', '500': '#4B5563',
900: '#030712', '600': '#374151',
}, '700': '#1F2937',
purple: { '800': '#111827',
50: '#F3E8FF', '900': '#030712'
100: '#E9D5FF', },
200: '#D8B4FE', purple: {
} '50': '#F3E8FF',
}, '100': '#E9D5FF',
fontFamily: { '200': '#D8B4FE'
sans: ['Inter', 'system-ui', 'sans-serif'], },
}, background: 'hsl(var(--background))',
spacing: { foreground: 'hsl(var(--foreground))',
'18': '4.5rem', card: {
'80': '20rem', DEFAULT: 'hsl(var(--card))',
'88': '22rem', foreground: 'hsl(var(--card-foreground))'
}, },
borderRadius: { popover: {
'xl': '0.75rem', DEFAULT: 'hsl(var(--popover))',
'2xl': '1rem', foreground: 'hsl(var(--popover-foreground))'
'3xl': '1.5rem', },
}, muted: {
boxShadow: { DEFAULT: 'hsl(var(--muted))',
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)', foreground: 'hsl(var(--muted-foreground))'
'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)', destructive: {
}, DEFAULT: 'hsl(var(--destructive))',
animation: { foreground: 'hsl(var(--destructive-foreground))'
'float': 'float 3s ease-in-out infinite', },
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', border: 'hsl(var(--border))',
}, input: 'hsl(var(--input))',
keyframes: { ring: 'hsl(var(--ring))',
float: { chart: {
'0%, 100%': { transform: 'translateY(0px)' }, '1': 'hsl(var(--chart-1))',
'50%': { transform: 'translateY(-10px)' }, '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 path from "path"
import react from '@vitejs/plugin-react' 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({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}) })