feat: migrate landing page from Next.js to Astro

Replaces the heavy Next.js SSR app with Astro static site:
- Zero JS shipped for static pages (hero, community, CTA, footer)
- Server-side data fetching in Astro frontmatter (events, hackathon, testimonials)
- Only 4 React islands for interactive parts (mobile menu, roadmap voting, testimonials home, feature request)
- Google Fonts via <link> instead of next/font
- Nix build changed from Node.js systemd service to static nginx (mkStaticAppModule)
- npmDepsHash needs update (set to fake hash, CI will provide correct one)

Performance gains:
- No Node.js server process needed (was using ~10MB RAM)
- No Next.js runtime JS (~200KB+ saved)
- No framer-motion bundle (~130KB saved)
- Static HTML served directly by nginx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-10 22:10:52 +07:00
co-authored by Claude Opus 4.6
parent 0ade3c3e50
commit 95bf09c2fa
61 changed files with 4141 additions and 2259 deletions
-30
View File
@@ -1,30 +0,0 @@
{
"jsc": {
"target": "es2017",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"decoratorMetadata": true,
"legacyDecorator": true
},
"keepClassNames": true,
"externalHelpers": true,
"loose": true
},
"module": {
"type": "commonjs"
},
"sourceMaps": true,
"exclude": [
"jest.config.ts",
".*\\.spec.tsx?$",
".*\\.test.tsx?$",
"./src/jest-setup.ts$",
"./**/jest-setup.ts$",
".*.js$",
".*.d.ts$"
]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import react from '@astrojs/react';
export default defineConfig({
integrations: [react()],
vite: {
plugins: [tailwindcss()],
},
output: 'static',
});
-21
View File
@@ -1,21 +0,0 @@
import { FlatCompat } from '@eslint/eslintrc';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import js from '@eslint/js';
import { fixupConfigRules } from '@eslint/compat';
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
const compat = new FlatCompat({
baseDirectory: dirname(fileURLToPath(import.meta.url)),
recommendedConfig: js.configs.recommended,
});
export default [
...fixupConfigRules(compat.extends('next')),
...fixupConfigRules(compat.extends('next/core-web-vitals')),
...baseConfig,
...nx.configs['flat/react-typescript'],
{
ignores: ['.next/**/*'],
},
];
-6
View File
@@ -1,6 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
declare module '*.svg' {
const content: any;
export const ReactComponent: any;
export default content;
}
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./../../dist/apps/landing/.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-10
View File
@@ -1,10 +0,0 @@
const { composePlugins, withNx } = require('@nx/next');
const path = require('path');
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
const nextConfig = {
nx: {},
output: 'standalone',
};
module.exports = composePlugins(withNx)(nextConfig);
+19
View File
@@ -0,0 +1,19 @@
{
"name": "landing",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^5.7.10",
"@astrojs/react": "^4.2.1",
"@tailwindcss/vite": "^4.1.4",
"tailwindcss": "^4.1.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0"
}
}
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+4 -16
View File
@@ -1,24 +1,12 @@
{
"name": "landing",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/landing",
"sourceRoot": "apps/landing/src",
"projectType": "application",
"tags": [],
"// targets": "to see all targets run: nx show project landing --web",
"targets": {
"serve": {
"executor": "@nx/next:server",
"options": {
"buildTarget": "landing:build",
"dev": true
}
},
"build": {
"executor": "@nx/next:build",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/landing"
}
}
"dev": { "command": "cd apps/landing && npx astro dev" },
"build": { "command": "cd apps/landing && npx astro build" },
"preview": { "command": "cd apps/landing && npx astro preview" }
}
}
@@ -1,73 +0,0 @@
import SOCIALS from '@/data/socials.json';
import {
FaArrowRight,
FaDiscord,
FaGithub,
FaFacebook,
FaInstagram,
FaLinkedin,
FaTiktok,
} from 'react-icons/fa';
const ICON_MAP: Record<string, typeof FaArrowRight> = {
FaFacebook, FaDiscord, FaGithub, FaInstagram, FaTiktok, FaLinkedin,
};
const COLOR_MAP: Record<string, string> = {
FaFacebook: 'text-[#1877F2]',
FaDiscord: 'text-[#5865F2]',
FaGithub: 'text-[#000000]',
FaInstagram: 'text-[#E4405F]',
FaTiktok: 'text-[#000000]',
FaLinkedin: 'text-[#0A66C2]',
};
export function CommunitySection() {
return (
<section id="community" className="w-full py-20 md:py-28">
<div className="container">
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
Bergabung dengan Komunitas Kami di
<span className="block mt-2 text-primary-500">Berbagai Platform</span>
</h2>
<p className="max-w-[600px] mx-auto text-gray-600 md:text-lg/relaxed mt-4">
Terhubung dengan sesama developer di komunitas kami
</p>
</div>
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{SOCIALS.map((community, index) => {
const IconComponent = ICON_MAP[community.icon] || FaArrowRight;
const iconColor = COLOR_MAP[community.icon] || 'text-primary-500';
return (
<div
key={index}
className="group relative p-8 bg-white border border-gray-200 rounded-xl hover:border-gray-300 transition-all duration-300 animate-[fadeInUp_0.4s_ease-out]"
style={{ animationDelay: `${index * 80}ms`, animationFillMode: 'both' }}
>
<div className="flex flex-col items-start gap-5">
<div className="flex items-center gap-4">
<IconComponent className={`w-8 h-8 ${iconColor} transition-colors`} />
<h3 className="text-xl font-semibold text-gray-900">{community.name}</h3>
</div>
<p className="text-gray-600 text-sm/relaxed">{community.description}</p>
<a
href={community.link}
className="inline-flex items-center gap-2 mt-2 text-sm font-medium transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<span>Jelajahi Komunitas</span>
<FaArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}
File diff suppressed because one or more lines are too long
@@ -1,109 +0,0 @@
'use client';
import HERO_CONTENT from '@/data/hero-content.json';
import HERO_STATS from '@/data/hero-stats.json';
import { Button } from '@components';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { Fragment } from 'react';
export function HeroSection() {
const router = useRouter();
const { communityLabel, headingLine1, headingLine2, description, buttons } =
HERO_CONTENT;
return (
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
<div className="absolute top-1/4 -left-20 w-80 h-80 rounded-full bg-gradient-to-r from-primary/20 to-blue-400/20 blur-3xl opacity-60" />
<div className="absolute bottom-1/3 -right-20 w-80 h-80 rounded-full bg-gradient-to-r from-blue-400/20 to-primary/20 blur-3xl opacity-60" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] bg-[size:40px_40px]" />
</div>
<div className="container px-4 md:px-6 relative">
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<div className="flex flex-col justify-center space-y-8 animate-[fadeInUp_0.5s_ease-out]">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm">
{communityLabel}
</span>
<a
href="https://ancikri.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-full border px-3 py-1 text-sm hover:bg-muted transition-colors"
>
Powered By Ancikri
</a>
</div>
<div className="space-y-4">
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
{headingLine1}{' '}
<span className="bg-clip-text bg-gradient-to-r text-primary-500">
{headingLine2}
</span>
</h1>
<p className="max-w-[600px] text-muted-foreground md:text-xl">
{description}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<Button size="lg" onClick={() => router.push(buttons.joinUrl)}>
{buttons.join}
</Button>
<Button
size="lg"
variant="bordered"
className="w-full sm:w-auto"
onClick={() => router.push(buttons.exploreUrl)}
>
{buttons.explore}
</Button>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-6 sm:gap-8">
{HERO_STATS.map(({ value, label }, i) => (
<Fragment key={i}>
<div className="flex flex-col items-center">
<div className="text-2xl font-bold bg-clip-text text-primary-500">
{value}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
{i < HERO_STATS.length - 1 && (
<div className="hidden sm:block h-10 border-r border-border mx-4" />
)}
</Fragment>
))}
</div>
</div>
<div className="relative w-full lg:w-auto mx-auto lg:ml-auto animate-[fadeIn_0.5s_ease-out_0.2s_both]">
<Image
src="/logo.webp"
alt="logo"
width={600}
height={500}
className="w-full h-auto object-cover"
priority
/>
</div>
</div>
</div>
<style jsx global>{`
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}</style>
</section>
);
}
@@ -1,14 +0,0 @@
import { CommunitySection } from './_components/community-section';
import { CTASection } from './_components/cta-section';
import { HeroSection } from './_components/hero-section';
import { TestimonialSection } from './_components/testimonial-section';
export default function Page() {
return (
<>
<HeroSection />
<CommunitySection />
<TestimonialSection />
<CTASection />
</>
);
}
@@ -1,126 +0,0 @@
import { LogoSimple } from '@/app/_components/logo';
import NAVIGATIONS from '@/data/navigations.json';
import SOCIALS from '@/data/socials.json';
import Link from 'next/link';
import {
FaDiscord,
FaFacebook,
FaGithub,
FaInstagram,
FaLinkedinIn,
FaTiktok,
} from 'react-icons/fa';
export default function Footer() {
return (
<footer className="w-full border-t bg-background py-12 md:py-16">
<div className="container px-4 md:px-6">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<LogoSimple />
</div>
<p className="text-sm text-muted-foreground">
Ingin Menjadi Programmer Handal Namun Enggan Ngoding
</p>
<div className="flex space-x-4">
<Link
href="https://fb.com/groups/programmerhandal"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaFacebook className="size-6" />
</Link>
<Link
href="https://discord.gg/imphnen"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaDiscord className="size-6" />
</Link>
<Link
href="https://github.com/IMPHNEN/"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaGithub className="size-6" />
</Link>
<Link
href="https://www.instagram.com/imphnen.dev"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaInstagram className="size-6" />
</Link>
<Link
href="https://www.tiktok.com/@imphnen"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaTiktok className="size-6" />
</Link>
<Link
href="https://www.linkedin.com/company/imphnen"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaLinkedinIn className="size-6" />
</Link>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Halaman</h3>
<ul className="space-y-2">
{NAVIGATIONS.map(({ link, title }) => (
<li key={link}>
<Link
href={link}
className="text-sm text-muted-foreground hover:text-foreground"
>
{title}
</Link>
</li>
))}
</ul>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Link</h3>
<ul className="space-y-2">
{SOCIALS.map(({ link, name }) => (
<li key={name}>
<Link
href={link}
className="text-sm text-muted-foreground hover:text-foreground"
target="_blank"
>
{name}
</Link>
</li>
))}
</ul>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Partners</h3>
<ul className="space-y-2"></ul>
</div>
</div>
<div className="mt-8 border-t pt-8 text-center space-y-2">
<p className="text-xs text-muted-foreground">
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
Handal, Namun Enggan Ngoding. All rights reserved.
</p>
<p className="text-xs text-muted-foreground">
Powered by{' '}
<Link
href="https://ancikri.com"
target="_blank"
className="font-medium text-foreground hover:underline"
>
Ancikri
</Link>
</p>
</div>
</div>
</footer>
);
}
@@ -1,128 +0,0 @@
'use client';
import { LogoSimple } from '@/app/_components/logo';
import NAVIGATIONS from '@/data/navigations.json';
import { cn } from '@utils';
import { AnimatePresence, motion } from 'framer-motion';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { LuMenu, LuX } from 'react-icons/lu';
export function Header() {
const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
return () => {
document.body.style.overflow = 'auto';
};
}, [mobileMenuOpen]);
useEffect(() => {
setMobileMenuOpen(false);
}, [pathname]);
return (
<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>
<nav className="hidden md:flex items-center gap-8">
{NAVIGATIONS.map(({ link, title }) => (
<Link
key={link}
href={link}
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 block">
{title}
</span>
{pathname === link && (
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-primary-500" />
)}
</Link>
))}
</nav>
<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 }}
>
{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>
</motion.div>
)}
</AnimatePresence>
</div>
</header>
);
}
@@ -1,39 +0,0 @@
'use client';
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();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
return (
<Button
variant="bordered"
size="icon"
onClick={toggleTheme}
className="focus-visible:ring-0 cursor-pointer"
>
{theme === 'dark' ? (
<LuSun className="h-[1.2rem] w-[1.2rem]" />
) : (
<LuMoon className="h-[1.2rem] w-[1.2rem]" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
);
}
@@ -1,241 +0,0 @@
'use client';
import { buttonVariants } from '@components';
import { cn } from '@utils';
import { useEffect, useState } from 'react';
import { HiCalendar, HiClock, HiLocationMarker } from 'react-icons/hi';
interface ApiEvent {
id: number;
name: string;
description: string;
detail_link: string;
price: number;
is_online: boolean;
is_deleted: boolean;
location: string;
start_date: string;
end_date: string;
created_at: string;
updated_at: string;
}
interface ApiResponse {
data: ApiEvent[];
meta: Record<string, unknown>;
version: string;
}
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Jakarta',
});
};
const getEventStatus = (endDate: string) => {
const now = new Date();
const end = new Date(endDate);
return end > now ? 'upcoming' : 'past';
};
export default function EventsPage() {
const [events, setEvents] = useState<ApiEvent[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('https://api.imphnen.dev/v1/landing/cms/events')
.then((res) => res.json())
.then((json: ApiResponse) => {
setEvents(json.data?.filter((e) => !e.is_deleted) || []);
})
.catch(() => setEvents([]))
.finally(() => setLoading(false));
}, []);
const sortedEvents = [...events].sort(
(a, b) =>
new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
);
if (loading) {
return (
<section className="min-h-screen bg-background container py-10">
<div className="flex justify-center items-center py-20">
<div className="w-8 h-8 border-3 border-gray-200 border-t-primary-500 rounded-full animate-spin" />
</div>
</section>
);
}
if (sortedEvents.length === 0) {
return (
<section className="min-h-screen bg-background container py-10">
<p className="text-center text-muted-foreground">
Belum ada event tersedia.
</p>
</section>
);
}
return (
<section className="min-h-screen bg-background container py-10">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="col-span-full">
<div className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden bg-card">
<div className="p-8 flex flex-col">
<div className="flex items-center gap-2 mb-4">
<span className="bg-primary/20 text-primary text-xs px-2.5 py-1 rounded-full">
Event Terbaru
</span>
<span
className={cn(
'px-2 py-1 rounded-full text-xs',
getEventStatus(sortedEvents[0].end_date) === 'upcoming'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{getEventStatus(sortedEvents[0].end_date) === 'upcoming'
? 'Upcoming'
: 'Selesai'}
</span>
<span
className={cn(
'px-2 py-1 rounded-full text-xs',
sortedEvents[0].is_online
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
)}
>
{sortedEvents[0].is_online ? 'Online' : 'Onsite'}
</span>
</div>
<h2 className="text-2xl font-bold mb-4 text-foreground">
{sortedEvents[0].name}
</h2>
<div className="space-y-3 mb-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<HiCalendar className="w-4 h-4" />
<span>{formatDate(sortedEvents[0].start_date)}</span>
</div>
<div className="flex items-center gap-2">
<HiClock className="w-4 h-4" />
<span>
{formatTime(sortedEvents[0].start_date)} -{' '}
{formatTime(sortedEvents[0].end_date)} WIB
</span>
</div>
<div className="flex items-center gap-2">
<HiLocationMarker className="w-4 h-4" />
<span>
{sortedEvents[0].is_online
? 'Online'
: sortedEvents[0].location}
</span>
</div>
{sortedEvents[0].price > 0 && (
<div className="mt-1 font-medium">
Rp {sortedEvents[0].price.toLocaleString('id-ID')}
</div>
)}
</div>
<p className="text-muted-foreground mb-6 line-clamp-4">
{sortedEvents[0].description}
</p>
<a
href={sortedEvents[0].detail_link}
target="_blank"
className={cn(
buttonVariants({ variant: 'bordered' }),
'mt-auto w-full md:w-fit'
)}
>
Lihat Detail
</a>
</div>
</div>
</div>
{sortedEvents.slice(1).map((event) => (
<div
key={event.id}
className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 bg-card"
>
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<span
className={cn(
'px-2 py-1 rounded-full text-xs',
getEventStatus(event.end_date) === 'upcoming'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{getEventStatus(event.end_date) === 'upcoming'
? 'Upcoming'
: 'Selesai'}
</span>
<span
className={cn(
'px-2 py-1 rounded-full text-xs',
event.is_online
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
)}
>
{event.is_online ? 'Online' : 'Onsite'}
</span>
</div>
<h3 className="text-lg font-semibold mb-3 text-foreground">
{event.name}
</h3>
<div className="space-y-2 mb-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<HiCalendar className="w-4 h-4" />
<span>{formatDate(event.start_date)}</span>
</div>
<div className="flex items-center gap-2">
<HiLocationMarker className="w-4 h-4" />
<span>
{event.is_online ? 'Online' : event.location}
</span>
</div>
{event.price > 0 && (
<div className="font-medium">
Rp {event.price.toLocaleString('id-ID')}
</div>
)}
</div>
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{event.description}
</p>
<a
href={event.detail_link}
target="_blank"
className={cn(
buttonVariants({ variant: 'bordered' }),
'w-full text-sm'
)}
>
Lihat Detail
</a>
</div>
</div>
))}
</div>
</section>
);
}
@@ -1,159 +0,0 @@
'use client';
import { buttonVariants } from '@components';
import { cn } from '@utils';
import { useEffect, useState } from 'react';
import { HiOutlineCode, HiOutlineTrophy } from 'react-icons/hi';
import { motion } from 'framer-motion';
interface TeamItem {
id: string;
name: string;
description: string;
city: string;
logo: string | null;
banner: string | null;
}
interface WinnerItem {
id: string;
team_id: string;
team_name: string;
rank: number;
prize: string | null;
}
export default function HackathonsPage() {
const [teams, setTeams] = useState<TeamItem[]>([]);
const [winners, setWinners] = useState<WinnerItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
fetch('https://api.imphnen.dev/v1/hackathon/teams/browse?per_page=20')
.then((r) => r.json())
.then((j) => j.data?.data || [])
.catch(() => []),
fetch('https://api.imphnen.dev/v1/hackathon/winners')
.then((r) => r.json())
.then((j) => j.data || [])
.catch(() => []),
]).then(([t, w]) => {
setTeams(t);
setWinners(w);
setLoading(false);
});
}, []);
const getWinnerRank = (teamId: string) => {
const w = winners.find((x) => x.team_id === teamId);
return w ? w.rank : null;
};
if (loading) {
return (
<section className="min-h-screen bg-background container py-10 flex items-center justify-center">
<div className="w-8 h-8 border-3 border-gray-200 border-t-primary-500 rounded-full animate-spin" />
</section>
);
}
if (teams.length === 0) {
return (
<section className="min-h-screen bg-background container py-10 flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground text-lg mb-2">No hackathon teams yet.</p>
<a href="https://hackathon.imphnen.dev" className={cn(buttonVariants(), 'mt-4')}>
Join Hackathon
</a>
</div>
</section>
);
}
return (
<section className="min-h-screen bg-background container py-10">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-3 text-foreground">IMPHNEN Hackathon</h1>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Tim-tim yang berpartisipasi dalam hackathon IMPHNEN
</p>
</div>
{winners.length > 0 && (
<div className="mb-12">
<h2 className="text-2xl font-bold mb-6 text-foreground flex items-center gap-2">
<HiOutlineTrophy className="text-amber-500" /> Pemenang
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{winners.sort((a, b) => a.rank - b.rank).map((w) => (
<div key={w.id} className="bg-amber-50 border-2 border-amber-200 rounded-xl p-6 text-center">
<div className="text-4xl mb-2">{w.rank === 1 ? '🥇' : w.rank === 2 ? '🥈' : '🥉'}</div>
<h3 className="text-lg font-bold text-gray-900">{w.team_name}</h3>
<p className="text-sm text-amber-700 mt-1">Juara {w.rank}</p>
{w.prize && <p className="text-xs text-amber-600 mt-1">{w.prize}</p>}
</div>
))}
</div>
</div>
)}
<h2 className="text-2xl font-bold mb-6 text-foreground">Semua Tim</h2>
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
initial="hidden"
animate="visible"
variants={{ visible: { transition: { staggerChildren: 0.08 } } }}
>
{teams.map((team, idx) => {
const rank = getWinnerRank(team.id);
return (
<motion.div
key={team.id}
className={cn(
'rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 bg-card group overflow-hidden',
rank ? 'ring-2 ring-amber-300' : ''
)}
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: idx * 0.06 }}
>
{team.banner ? (
<div className="h-40 bg-muted relative overflow-hidden">
<img src={team.banner} alt={team.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div>
) : (
<div className="h-40 bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center">
<HiOutlineCode className="w-12 h-12 text-primary-400" />
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-foreground">{team.name}</h3>
{rank && (
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-full font-medium">
Juara {rank}
</span>
)}
</div>
{team.city && (
<p className="text-xs text-muted-foreground mb-2">{team.city}</p>
)}
{team.description && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">{team.description}</p>
)}
<a
href={`https://hackathon.imphnen.dev/teams/${team.id}`}
target="_blank"
className={cn(buttonVariants({ variant: 'bordered' }), 'w-full text-sm')}
>
Lihat Tim
</a>
</div>
</motion.div>
);
})}
</motion.div>
</section>
);
}
-12
View File
@@ -1,12 +0,0 @@
import Footer from './_components/footer';
import { Header } from './_components/header';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}
@@ -1,35 +0,0 @@
'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>
);
}
@@ -1,251 +0,0 @@
'use client';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@components';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { BiUpvote } from 'react-icons/bi';
import { FiCheckCircle } from 'react-icons/fi';
import { MdOutlineOpenInNew } from 'react-icons/md';
interface RoadmapItem {
id: string;
title: string;
description: string;
status: 'upcoming' | 'in_progress' | 'completed';
votes: number;
created_at: string;
}
export default function ProjectsVote() {
const [items, setItems] = useState<RoadmapItem[]>([]);
const [loading, setLoading] = useState(true);
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
useEffect(() => {
fetch('https://api.imphnen.dev/v1/landing/cms/roadmap')
.then((r) => r.json())
.then((json) => {
setItems(json.data || []);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const upcomingItems = items.filter((i) => i.status === 'upcoming');
const inProgressItems = items.filter((i) => i.status === 'in_progress');
const completedItems = items.filter((i) => i.status === 'completed');
const handleVote = (id: string) => {
const alreadyVoted = votedIds.has(id);
// Optimistic update
setItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, votes: item.votes + (alreadyVoted ? -1 : 1) }
: item
)
);
if (alreadyVoted) {
setVotedIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
} else {
setVotedIds((prev) => new Set(prev).add(id));
fetch(`https://api.imphnen.dev/v1/landing/cms/roadmap/vote/${id}`, {
method: 'POST',
}).catch(() => {});
}
};
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 } },
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-3 border-gray-200 border-t-primary-500 rounded-full animate-spin" />
</div>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-7xl mx-auto">
<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) => (
<motion.div key={item.id} 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(item.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
votedIds.has(item.id)
? 'bg-primary-500 text-white hover:bg-primary-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<motion.span
animate={{ scale: votedIds.has(item.id) ? [1, 1.2, 1] : 1 }}
transition={{ duration: 0.2 }}
>
<BiUpvote
className={`w-4 h-4 ${
votedIds.has(item.id) ? 'text-white' : 'text-gray-600'
}`}
/>
</motion.span>
<span>{votedIds.has(item.id) ? '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>
))}
{upcomingItems.length === 0 && (
<p className="text-gray-500 text-sm px-2">No upcoming items yet.</p>
)}
</motion.div>
</AnimatePresence>
</div>
<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={item.id} 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">In development</p>
</div>
</CardContent>
</Card>
</motion.div>
))}
{inProgressItems.length === 0 && (
<p className="text-gray-500 text-sm px-2">Nothing in progress.</p>
)}
</motion.div>
</AnimatePresence>
</div>
<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) => (
<motion.div key={item.id} 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>
))}
{completedItems.length === 0 && (
<p className="text-gray-500 text-sm px-2">No completed items yet.</p>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
@@ -1,120 +0,0 @@
'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>
);
}
@@ -1,11 +0,0 @@
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 <></>;
}
@@ -1,98 +0,0 @@
'use client';
import { Button } from '@components';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { BsChatLeftQuote } from 'react-icons/bs';
import { FaQuoteLeft } from 'react-icons/fa';
interface ApiTestimonial {
id: number;
user_id: number;
user_fullname: string;
role: string;
content: string;
created_at: string;
is_deleted: boolean;
}
interface ApiResponse {
data: ApiTestimonial[];
meta: Record<string, unknown>;
version: string;
}
const AVATAR_COLORS = [
'bg-primary-500 text-white',
'bg-blue-500 text-white',
'bg-green-500 text-white',
'bg-purple-500 text-white',
'bg-orange-500 text-white',
'bg-pink-500 text-white',
];
function getAvatarColor(index: number) {
return AVATAR_COLORS[index % AVATAR_COLORS.length];
}
function getInitial(name: string) {
return name.charAt(0).toUpperCase();
}
export default function Page() {
const [testimonials, setTestimonials] = useState<ApiTestimonial[]>([]);
useEffect(() => {
fetch('https://api.imphnen.dev/v1/landing/cms/testimonials')
.then((res) => res.json())
.then((json: ApiResponse) => {
setTestimonials(json.data?.filter((t: ApiTestimonial) => !t.is_deleted) || []);
})
.catch(() => setTestimonials([]));
}, []);
return (
<div className="min-h-screen">
<div className="py-16 px-4 text-center border-b border-border">
<BsChatLeftQuote className="size-14 mx-auto my-4 text-foreground" />
<h1 className="text-4xl font-bold mb-4 text-foreground">Testimonial</h1>
<p className="max-w-2xl mx-auto text-lg mb-8 text-balance text-muted-foreground">
Menampilkan pengalaman dan cerita para member yang telah join ke dalam
komunitas kami
</p>
<Link href="/testimonials/submit">
<Button>Tulis Testimonialmu</Button>
</Link>
</div>
<div className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 my-16 container">
{testimonials.map((testimonial, index) => (
<div
key={testimonial.id}
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
>
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-4">
<div
className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}
>
{getInitial(testimonial.user_fullname)}
</div>
<div>
<h4 className="font-semibold text-gray-900">
{testimonial.user_fullname}
</h4>
<p className="text-sm text-gray-600">{testimonial.role}</p>
</div>
</div>
<div className="text-gray-600 relative">
<FaQuoteLeft className="text-primary-500/30 w-6 h-6 mb-2" />
<p className="text-sm/relaxed">{testimonial.content}</p>
</div>
</div>
</div>
))}
</div>
</div>
);
}
@@ -1,3 +0,0 @@
export default function Page() {
return <></>;
}
File diff suppressed because one or more lines are too long
@@ -1,17 +0,0 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ThemeProviderProps } from 'next-themes';
import { ReactNode } from 'react';
export function Providers({ children }: ThemeProviderProps) {
return <QueryProvider>{children}</QueryProvider>;
}
export const queryClient = new QueryClient();
function QueryProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
@@ -1,28 +0,0 @@
'use client';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="light"
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };
@@ -1,6 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('https://forms.gle/fNzQWSdqXoyFsf8V6');
return null;
}
-52
View File
@@ -1,52 +0,0 @@
import { poppinsFont } from '@/lib/fonts';
import '@/styles/globals.css';
import { cn } from '@utils';
import { type Metadata } from 'next';
import { Providers } from './_components/providers';
import { Toaster } from './_components/toaster';
export const metadata: Metadata = {
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description: 'Komunitas belajar programming untuk semua level',
openGraph: {
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description: 'Komunitas belajar programming untuk semua level',
url: 'https://imphnen.dev',
images: [
{
url: 'https://imphnen.dev/imphnen-group-cover.webp',
alt: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
width: 2550,
height: 945,
},
],
},
twitter: {
card: 'summary_large_image',
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description: 'Komunitas belajar programming untuk semua level',
images: ['https://imphnen.dev/imphnen-group-cover.webp'],
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="id" suppressHydrationWarning>
<body className={cn(poppinsFont.className, 'antialiased')}>
<Providers
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster richColors />
</Providers>
</body>
</html>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
---
import navigations from '../data/navigations.json';
import MobileMenu from './MobileMenu';
const currentPath = Astro.url.pathname;
---
<header class="sticky top-0 w-full z-50 bg-background/70 backdrop-blur-sm">
<div class="container flex h-20 items-center justify-between">
<a href="/" class="relative overflow-hidden rounded z-50" aria-label="Home">
<img src="/logo.webp" alt="IMPHNEN" class="w-24 md:w-28 h-auto" />
</a>
<nav class="hidden md:flex items-center gap-8">
{navigations.map(({ link, title }: { link: string; title: string }) => (
<a
href={link}
class:list={[
'text-sm font-medium relative group px-1 py-2',
currentPath === link ? 'text-primary' : 'text-foreground/90',
]}
>
<span class="transition-colors hover:text-primary block">{title}</span>
{currentPath === link && (
<span class="absolute bottom-0 left-0 w-full h-0.5 bg-primary-500" />
)}
</a>
))}
</nav>
<div class="md:hidden">
<MobileMenu client:idle navigations={navigations} currentPath={currentPath} />
</div>
</div>
</header>
@@ -0,0 +1,88 @@
---
import heroContent from '../data/hero-content.json';
import heroStats from '../data/hero-stats.json';
const { communityLabel, headingLine1, headingLine2, description, buttons } = heroContent;
---
<section class="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
<div class="absolute inset-0 -z-10 overflow-hidden">
<div class="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50"></div>
<div class="absolute top-1/4 -left-20 w-80 h-80 rounded-full bg-gradient-to-r from-primary/20 to-blue-400/20 blur-3xl opacity-60"></div>
<div class="absolute bottom-1/3 -right-20 w-80 h-80 rounded-full bg-gradient-to-r from-blue-400/20 to-primary/20 blur-3xl opacity-60"></div>
<div class="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] bg-[size:40px_40px]"></div>
</div>
<div class="container px-4 md:px-6 relative">
<div class="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<div class="flex flex-col justify-center space-y-8 animate-[fadeInUp_0.5s_ease-out]">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-full border px-3 py-1 text-sm">
{communityLabel}
</span>
<a
href="https://ancikri.com"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-full border px-3 py-1 text-sm hover:bg-muted transition-colors"
>
Powered By Ancikri
</a>
</div>
<div class="space-y-4">
<h1 class="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
{headingLine1}{' '}
<span class="bg-clip-text bg-gradient-to-r text-primary-500">
{headingLine2}
</span>
</h1>
<p class="max-w-[600px] text-muted-foreground md:text-xl">
{description}
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<a
href={buttons.joinUrl}
class="inline-flex items-center justify-center rounded-lg bg-primary text-primary-foreground px-6 py-3 text-sm font-medium hover:bg-primary/90 transition-colors"
>
{buttons.join}
</a>
<a
href={buttons.exploreUrl}
class="inline-flex items-center justify-center rounded-lg border border-input bg-background px-6 py-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors w-full sm:w-auto"
>
{buttons.explore}
</a>
</div>
<div class="flex flex-wrap justify-center md:justify-start gap-6 sm:gap-8">
{heroStats.map(({ value, label }: { value: string; label: string }, i: number) => (
<>
<div class="flex flex-col items-center">
<div class="text-2xl font-bold bg-clip-text text-primary-500">
{value}
</div>
<div class="text-xs text-muted-foreground">{label}</div>
</div>
{i < heroStats.length - 1 && (
<div class="hidden sm:block h-10 border-r border-border mx-4"></div>
)}
</>
))}
</div>
</div>
<div class="relative w-full lg:w-auto mx-auto lg:ml-auto animate-[fadeIn_0.5s_ease-out_0.2s_both]">
<img
src="/logo.webp"
alt="logo"
width="600"
height="500"
class="w-full h-auto object-cover"
/>
</div>
</div>
</div>
</section>
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
import { LuMenu, LuX } from 'react-icons/lu';
interface Navigation {
title: string;
link: string;
}
interface Props {
navigations: Navigation[];
currentPath: string;
}
export default function MobileMenu({ navigations, currentPath }: Props) {
const [open, setOpen] = useState(false);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
return () => {
document.body.style.overflow = 'auto';
};
}, [open]);
return (
<>
<button
onClick={() => setOpen((prev) => !prev)}
className="relative z-50 p-2 rounded-lg hover:bg-muted transition-colors"
aria-label={open ? 'Tutup menu' : 'Buka menu'}
>
{open ? <LuX className="size-6" /> : <LuMenu className="size-6" />}
</button>
{open && (
<div className="fixed inset-0 z-40 bg-background flex flex-col">
<div className="container flex h-20 items-center justify-between">
<a
href="/"
className="relative overflow-hidden rounded"
onClick={() => setOpen(false)}
>
<img src="/logo.webp" alt="IMPHNEN" className="w-24 h-auto" />
</a>
<button
onClick={() => setOpen(false)}
className="p-2 rounded-lg hover:bg-muted transition-colors"
aria-label="Tutup menu"
>
<LuX className="size-6" />
</button>
</div>
<nav className="flex-1 flex flex-col items-center justify-center gap-6 py-10">
{navigations.map(({ link, title }) => (
<a
key={link}
href={link}
className={`text-2xl font-medium py-2 px-6 rounded-lg transition-colors ${
currentPath === link
? 'text-primary bg-primary/10'
: 'text-foreground hover:bg-muted'
}`}
onClick={() => setOpen(false)}
>
{title}
</a>
))}
</nav>
</div>
)}
</>
);
}
@@ -0,0 +1,81 @@
import { useState } from 'react';
export default function RequestFeature() {
const [open, setOpen] = useState(false);
const [description, setDescription] = useState('');
const maxDescriptionLength = 10000;
return (
<>
<button
onClick={() => setOpen(true)}
className="inline-flex items-center justify-center rounded-lg bg-white text-gray-900 px-6 py-3 text-sm font-medium hover:bg-gray-100 transition-colors"
>
Request Fitur
</button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={() => setOpen(false)}
/>
<div className="relative bg-white rounded-xl p-6 w-full max-w-[600px] mx-4 shadow-xl">
<h2 className="text-2xl font-bold mb-6">Request Fitur</h2>
<form className="grid items-start gap-8">
<div className="grid gap-4">
<label htmlFor="namaFitur" className="font-medium text-sm">
Nama Fitur *
</label>
<input
id="namaFitur"
placeholder="Masukkan nama fitur yang diinginkan"
className="h-11 rounded-lg border border-input bg-background px-3 text-sm"
/>
</div>
<div className="grid gap-4">
<div className="flex justify-between items-center">
<label htmlFor="deskripsiFitur" className="font-medium text-sm">
Deskripsi Fitur *
</label>
<span className="text-sm text-gray-500">
{description.length}/{maxDescriptionLength}
</span>
</div>
<textarea
id="deskripsiFitur"
placeholder="Jelaskan fitur yang Anda inginkan secara detail"
maxLength={maxDescriptionLength}
rows={6}
className="min-h-[150px] rounded-lg border border-input bg-background px-3 py-2 text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-sm text-gray-500 -mt-2">
Jelaskan manfaat dan cara kerja fitur yang Anda inginkan
</p>
</div>
<div className="flex gap-3">
<button
type="submit"
className="inline-flex items-center justify-center rounded-lg bg-primary text-primary-foreground px-6 py-3 text-sm font-medium hover:bg-primary/90 transition-colors"
>
Kirim Permintaan
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="inline-flex items-center justify-center rounded-lg border border-input bg-background px-6 py-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { BiUpvote } from 'react-icons/bi';
import { FiCheckCircle } from 'react-icons/fi';
import { MdOutlineOpenInNew } from 'react-icons/md';
interface RoadmapItem {
id: string;
title: string;
description: string;
status: 'upcoming' | 'in_progress' | 'completed';
votes: number;
created_at: string;
}
export default function RoadmapVote() {
const [items, setItems] = useState<RoadmapItem[]>([]);
const [loading, setLoading] = useState(true);
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
useEffect(() => {
fetch('https://api.imphnen.dev/v1/landing/cms/roadmap')
.then((r) => r.json())
.then((json) => {
setItems(json.data || []);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const upcomingItems = items.filter((i) => i.status === 'upcoming');
const inProgressItems = items.filter((i) => i.status === 'in_progress');
const completedItems = items.filter((i) => i.status === 'completed');
const handleVote = (id: string) => {
const alreadyVoted = votedIds.has(id);
setItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, votes: item.votes + (alreadyVoted ? -1 : 1) }
: item
)
);
if (alreadyVoted) {
setVotedIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
} else {
setVotedIds((prev) => new Set(prev).add(id));
fetch(`https://api.imphnen.dev/v1/landing/cms/roadmap/vote/${id}`, {
method: 'POST',
}).catch(() => {});
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-3 border-gray-200 border-t-primary-500 rounded-full animate-spin" />
</div>
);
}
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>
<div className="space-y-5">
{upcomingItems.map((item) => (
<div
key={item.id}
className="bg-white border border-gray-200 hover:border-primary-200 shadow-sm hover:shadow-md transition-all rounded-xl overflow-hidden"
>
<div className="p-4 pb-3">
<h3 className="text-lg font-semibold text-gray-900">
{item.title}
</h3>
</div>
<div className="px-4 pb-4">
<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">
<button
onClick={() => handleVote(item.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
votedIds.has(item.id)
? 'bg-primary-500 text-white hover:bg-primary-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<BiUpvote
className={`w-4 h-4 ${
votedIds.has(item.id) ? 'text-white' : 'text-gray-600'
}`}
/>
<span>{votedIds.has(item.id) ? 'Voted' : 'Vote'}</span>
</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>
</div>
</div>
))}
{upcomingItems.length === 0 && (
<p className="text-gray-500 text-sm px-2">No upcoming items yet.</p>
)}
</div>
</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>
<div className="space-y-5">
{inProgressItems.map((item, index) => (
<div
key={item.id}
className="bg-white border border-gray-200 hover:border-primary-200 shadow-sm hover:shadow-md transition-all rounded-xl overflow-hidden"
>
<div className="p-4 pb-3">
<h3 className="text-lg font-semibold text-gray-900">
{item.title}
</h3>
</div>
<div className="px-4 pb-4">
<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">In development</p>
</div>
</div>
</div>
))}
{inProgressItems.length === 0 && (
<p className="text-gray-500 text-sm px-2">Nothing in progress.</p>
)}
</div>
</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>
<div className="space-y-5">
{completedItems.map((item) => (
<div
key={item.id}
className="bg-white border border-gray-200 hover:border-green-200 shadow-sm hover:shadow-md transition-all rounded-xl overflow-hidden"
>
<div className="p-4 pb-3">
<h3 className="text-lg font-semibold text-gray-900">
{item.title}
</h3>
</div>
<div className="px-4 pb-4">
<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 className="inline-flex items-center gap-2 rounded-lg bg-primary text-primary-foreground px-3 py-1.5 text-sm font-medium hover:bg-primary/90 transition-colors">
Coba sekarang
<MdOutlineOpenInNew className="size-4" />
</button>
</div>
</div>
</div>
))}
{completedItems.length === 0 && (
<p className="text-gray-500 text-sm px-2">No completed items yet.</p>
)}
</div>
</div>
</div>
);
}
@@ -1,7 +1,3 @@
'use client';
import { buttonVariants } from '@components';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { FaQuoteLeft } from 'react-icons/fa';
@@ -39,18 +35,23 @@ function getInitial(name: string) {
return name.charAt(0).toUpperCase();
}
export function TestimonialSection() {
export default function TestimonialSection() {
const [testimonials, setTestimonials] = useState<Testimonial[]>([]);
useEffect(() => {
fetch('https://api.imphnen.dev/v1/landing/cms/testimonials')
.then((res) => res.ok ? res.json() : Promise.reject())
.then((res) => (res.ok ? res.json() : Promise.reject()))
.then((json) => {
setTestimonials(
(json.data as ApiTestimonial[])
.filter((t) => !t.is_deleted)
.slice(0, 6)
.map((t) => ({ id: t.id, name: t.user_fullname, role: t.role, text: t.content }))
.map((t) => ({
id: t.id,
name: t.user_fullname,
role: t.role,
text: t.content,
}))
);
})
.catch(() => setTestimonials([]));
@@ -71,15 +72,22 @@ export function TestimonialSection() {
<div
key={testimonial.id}
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300 animate-[fadeInUp_0.4s_ease-out]"
style={{ animationDelay: `${index * 100}ms`, animationFillMode: 'both' }}
style={{
animationDelay: `${index * 100}ms`,
animationFillMode: 'both',
}}
>
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}>
<div
className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}
>
{getInitial(testimonial.name)}
</div>
<div>
<h4 className="font-semibold text-gray-900">{testimonial.name}</h4>
<h4 className="font-semibold text-gray-900">
{testimonial.name}
</h4>
<p className="text-sm text-gray-600">{testimonial.role}</p>
</div>
</div>
@@ -93,9 +101,12 @@ export function TestimonialSection() {
</div>
<div className="flex justify-center">
<Link href="/testimonials" className={buttonVariants({ size: 'lg' })}>
<a
href="/testimonials"
className="inline-flex items-center justify-center rounded-lg bg-primary text-primary-foreground px-6 py-3 text-sm font-medium hover:bg-primary/90 transition-colors"
>
Tulis Testimonimu
</Link>
</a>
</div>
</div>
</section>
-30
View File
@@ -1,30 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQueryList = window.matchMedia(query);
setMatches(mediaQueryList.matches);
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQueryList.addEventListener('change', listener);
return () => {
mediaQueryList.removeEventListener('change', listener);
};
}, [query]);
return matches;
}
export default useMediaQuery;
+51
View File
@@ -0,0 +1,51 @@
---
import '../styles/globals.css';
interface Props {
title?: string;
description?: string;
}
const {
title = 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description = 'Komunitas belajar programming untuk semua level',
} = Astro.props;
---
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content="https://imphnen.dev" />
<meta property="og:image" content="https://imphnen.dev/imphnen-group-cover.webp" />
<meta property="og:image:alt" content="IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding" />
<meta property="og:image:width" content="2550" />
<meta property="og:image:height" content="945" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content="https://imphnen.dev/imphnen-group-cover.webp" />
<!-- Google Fonts: Poppins -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body class="font-[Poppins,sans-serif] antialiased">
<slot />
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
---
import Base from './Base.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
interface Props {
title?: string;
description?: string;
}
const { title, description } = Astro.props;
---
<Base title={title} description={description}>
<div class="flex min-h-screen flex-col">
<Header />
<main class="flex-1">
<slot />
</main>
<Footer />
</div>
</Base>
-24
View File
@@ -1,24 +0,0 @@
import { cookies } from 'next/headers';
const REFRESH_TOKEN_NAME = '__imphnen_refresh_token__';
const ACCESS_TOKEN_NAME = '__imphnen_access_token__';
export async function setRefreshToken(refreshToken: string) {
const cookieStore = await cookies();
cookieStore.set(REFRESH_TOKEN_NAME, refreshToken);
}
export async function setAccessToken(accessToken: string) {
const cookieStore = await cookies();
cookieStore.set(ACCESS_TOKEN_NAME, accessToken);
}
export async function getRefreshToken() {
const cookieStore = await cookies();
return cookieStore.get(REFRESH_TOKEN_NAME)?.value;
}
export async function getAccessToken() {
const cookieStore = await cookies();
return cookieStore.get(ACCESS_TOKEN_NAME)?.value;
}
-6
View File
@@ -1,6 +0,0 @@
import type { paths } from '@/openapi-types';
import createClient from 'openapi-fetch';
export const fetcher = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
});
-13
View File
@@ -1,13 +0,0 @@
import { Poppins, Bai_Jamjuree } from 'next/font/google';
export const poppinsFont = Poppins({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
display: 'swap',
});
export const baiJamjureeFont = Bai_Jamjuree({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
display: 'swap',
});
-10
View File
@@ -1,10 +0,0 @@
import { headers } from 'next/headers';
export async function getRemoteIp() {
const hdrs = await headers();
const xff = hdrs.get('x-forwarded-for');
if (!xff) return undefined;
const ips = xff.split(',').map((ip) => ip.trim());
return ips[0] || undefined;
}
-8
View File
@@ -1,8 +0,0 @@
import type { paths } from '@/openapi-types';
import createFetchClient from 'openapi-fetch';
import createClient from 'openapi-react-query';
const fetchClient = createFetchClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
});
export const rpc = createClient(fetchClient);
@@ -0,0 +1,13 @@
---
---
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta http-equiv="refresh" content="0;url=https://forms.gle/fNzQWSdqXoyFsf8V6" />
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting to registration form...</p>
</body>
</html>
+196
View File
@@ -0,0 +1,196 @@
---
import Public from '../layouts/Public.astro';
interface ApiEvent {
id: number;
name: string;
description: string;
detail_link: string;
price: number;
is_online: boolean;
is_deleted: boolean;
location: string;
start_date: string;
end_date: string;
created_at: string;
updated_at: string;
}
let events: ApiEvent[] = [];
try {
const res = await fetch('https://api.imphnen.dev/v1/landing/cms/events');
const json = await res.json();
events = (json.data || []).filter((e: ApiEvent) => !e.is_deleted);
} catch {
events = [];
}
const sortedEvents = [...events].sort(
(a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
);
function formatDate(dateString: string) {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
function formatTime(dateString: string) {
const date = new Date(dateString);
return date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Jakarta',
});
}
function getEventStatus(endDate: string) {
const now = new Date();
const end = new Date(endDate);
return end > now ? 'upcoming' : 'past';
}
---
<Public title="Events - IMPHNEN">
<section class="min-h-screen bg-background container py-10">
{sortedEvents.length === 0 ? (
<p class="text-center text-muted-foreground">
Belum ada event tersedia.
</p>
) : (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{/* Featured event */}
<div class="col-span-full">
<div class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden bg-card">
<div class="p-8 flex flex-col">
<div class="flex items-center gap-2 mb-4">
<span class="bg-primary/20 text-primary text-xs px-2.5 py-1 rounded-full">
Event Terbaru
</span>
<span
class:list={[
'px-2 py-1 rounded-full text-xs',
getEventStatus(sortedEvents[0].end_date) === 'upcoming'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground',
]}
>
{getEventStatus(sortedEvents[0].end_date) === 'upcoming' ? 'Upcoming' : 'Selesai'}
</span>
<span
class:list={[
'px-2 py-1 rounded-full text-xs',
sortedEvents[0].is_online
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700',
]}
>
{sortedEvents[0].is_online ? 'Online' : 'Onsite'}
</span>
</div>
<h2 class="text-2xl font-bold mb-4 text-foreground">
{sortedEvents[0].name}
</h2>
<div class="space-y-3 mb-6 text-sm text-muted-foreground">
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span>{formatDate(sortedEvents[0].start_date)}</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>
{formatTime(sortedEvents[0].start_date)} - {formatTime(sortedEvents[0].end_date)} WIB
</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
<span>
{sortedEvents[0].is_online ? 'Online' : sortedEvents[0].location}
</span>
</div>
{sortedEvents[0].price > 0 && (
<div class="mt-1 font-medium">
Rp {sortedEvents[0].price.toLocaleString('id-ID')}
</div>
)}
</div>
<p class="text-muted-foreground mb-6 line-clamp-4">
{sortedEvents[0].description}
</p>
<a
href={sortedEvents[0].detail_link}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center rounded-lg border border-input bg-background px-6 py-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors mt-auto w-full md:w-fit"
>
Lihat Detail
</a>
</div>
</div>
</div>
{/* Other events */}
{sortedEvents.slice(1).map((event) => (
<div class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 bg-card">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<span
class:list={[
'px-2 py-1 rounded-full text-xs',
getEventStatus(event.end_date) === 'upcoming'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground',
]}
>
{getEventStatus(event.end_date) === 'upcoming' ? 'Upcoming' : 'Selesai'}
</span>
<span
class:list={[
'px-2 py-1 rounded-full text-xs',
event.is_online
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700',
]}
>
{event.is_online ? 'Online' : 'Onsite'}
</span>
</div>
<h3 class="text-lg font-semibold mb-3 text-foreground">
{event.name}
</h3>
<div class="space-y-2 mb-4 text-sm text-muted-foreground">
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span>{formatDate(event.start_date)}</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
<span>{event.is_online ? 'Online' : event.location}</span>
</div>
{event.price > 0 && (
<div class="font-medium">
Rp {event.price.toLocaleString('id-ID')}
</div>
)}
</div>
<p class="text-muted-foreground text-sm mb-4 line-clamp-3">
{event.description}
</p>
<a
href={event.detail_link}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center rounded-lg border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors w-full"
>
Lihat Detail
</a>
</div>
</div>
))}
</div>
)}
</section>
</Public>

Some files were not shown because too many files have changed in this diff Show More