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
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>
);
}
@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react';
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 Testimonial {
id: number;
name: string;
role: string;
text: 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 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((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,
}))
);
})
.catch(() => setTestimonials([]));
}, []);
return (
<section className="w-full py-20 md:py-28 bg-gray-50">
<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 animate-[fadeInUp_0.4s_ease-out]">
Apa Kata Mereka Tentang
<span className="block mt-2 text-primary-500">Komunitas Kami?</span>
</h2>
</div>
<div className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mb-16">
{testimonials.map((testimonial, index) => (
<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',
}}
>
<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.name)}
</div>
<div>
<h4 className="font-semibold text-gray-900">
{testimonial.name}
</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.text}</p>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-center">
<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
</a>
</div>
</div>
</section>
);
}