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
@@ -0,0 +1,56 @@
---
---
<section class="w-full py-20 md:py-32 bg-gradient-to-br from-primary-500 to-primary-600 relative overflow-hidden">
<div
class="absolute inset-0 opacity-10"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg width='52' height='26' viewBox='0 0 52 26' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4v2c-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E&quot;);"
></div>
<div class="container relative">
<div class="flex flex-col lg:flex-row items-center justify-between gap-12">
<div class="flex-1 text-center lg:text-left space-y-8">
<div>
<h2 class="text-4xl md:text-5xl font-bold text-white leading-tight">
<span class="block">LET'S GO</span>
<span class="block text-5xl md:text-6xl mt-2">
<span class="text-yellow-300">SAAT</span><span class="text-white">NYA</span>
</span>
<span class="block mt-2">KAMU JOIN!</span>
</h2>
</div>
<p class="text-lg md:text-xl text-white/90 max-w-2xl mx-auto lg:mx-0">
Jadilah bagian dari komunitas developer terbesar di Indonesia.
Tingkatkan skill, perluas jaringan, dan raih kesempatan karir
bersama kami!
</p>
<div class="flex justify-center lg:justify-start">
<a
href="#community"
class="flex items-center gap-4 px-8 py-4 bg-yellow-300 hover:bg-yellow-400 text-gray-900 rounded-full text-lg font-semibold transition-all hover:gap-6 group"
>
<span>Join Sekarang</span>
<svg fill="currentColor" viewBox="0 0 448 512" class="w-5 h-5 transition-all group-hover:rotate-45"><path d="M438.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L338.8 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l306.7 0L233.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>
</a>
</div>
</div>
<div class="flex-1 max-w-xl">
<div class="relative p-8">
<div class="absolute inset-0 bg-white/10 rounded-3xl transform rotate-6"></div>
<div class="relative bg-white/5 rounded-3xl p-8 backdrop-blur-lg border border-white/10">
<div class="flex flex-col items-center gap-6 text-white">
<img src="/logo.webp" alt="IMPHNEN" class="w-[240px] h-auto" />
<div class="text-center space-y-2">
<h3 class="text-2xl font-bold">250.000+</h3>
<p class="text-lg">Programmer Sudah Bergabung</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
@@ -0,0 +1,66 @@
---
import socials from '../data/socials.json';
const ICON_SVG: Record<string, string> = {
FaFacebook: '<svg fill="currentColor" viewBox="0 0 320 512" class="w-8 h-8"><path d="M80 299.3V512H196V299.3h86.5l18-97.8H196V166.9c0-51.7 20.3-71.5 72.7-71.5c16.3 0 29.4.4 37 1.2V7.9C291.4 4 256.4 0 236.2 0C129.3 0 80 50.5 80 159.4v42.1H14v97.8H80z"/></svg>',
FaDiscord: '<svg fill="currentColor" viewBox="0 0 640 512" class="w-8 h-8"><path d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>',
FaGithub: '<svg fill="currentColor" viewBox="0 0 496 512" class="w-8 h-8"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8z"/></svg>',
FaInstagram: '<svg fill="currentColor" viewBox="0 0 448 512" class="w-8 h-8"><path d="M224.1 141c-63.6 0-114.9 51.3-114.9 114.9s51.3 114.9 114.9 114.9S339 319.5 339 255.9 287.7 141 224.1 141zm0 189.6c-41.1 0-74.7-33.5-74.7-74.7s33.5-74.7 74.7-74.7 74.7 33.5 74.7 74.7-33.6 74.7-74.7 74.7zm146.4-194.3c0 14.9-12 26.8-26.8 26.8-14.9 0-26.8-12-26.8-26.8s12-26.8 26.8-26.8 26.8 12 26.8 26.8zm76.1 27.2c-1.7-35.9-9.9-67.7-36.2-93.9-26.2-26.2-58-34.4-93.9-36.2-37-2.1-147.9-2.1-184.9 0-35.8 1.7-67.6 9.9-93.9 36.1s-34.4 58-36.2 93.9c-2.1 37-2.1 147.9 0 184.9 1.7 35.9 9.9 67.7 36.2 93.9s58 34.4 93.9 36.2c37 2.1 147.9 2.1 184.9 0 35.9-1.7 67.7-9.9 93.9-36.2 26.2-26.2 34.4-58 36.2-93.9 2.1-37 2.1-147.8 0-184.8zM398.8 388c-7.8 19.6-22.9 34.7-42.6 42.6-29.5 11.7-99.5 9-132.1 9s-102.7 2.6-132.1-9c-19.6-7.8-34.7-22.9-42.6-42.6-11.7-29.5-9-99.5-9-132.1s-2.6-102.7 9-132.1c7.8-19.6 22.9-34.7 42.6-42.6 29.5-11.7 99.5-9 132.1-9s102.7-2.6 132.1 9c19.6 7.8 34.7 22.9 42.6 42.6 11.7 29.5 9 99.5 9 132.1s2.7 102.7-9 132.1z"/></svg>',
FaTiktok: '<svg fill="currentColor" viewBox="0 0 448 512" class="w-8 h-8"><path d="M448,209.91a210.06,210.06,0,0,1-122.77-39.25V349.38A162.55,162.55,0,1,1,185,188.31V278.2a74.62,74.62,0,1,0,52.23,71.18V0l88,0a121.18,121.18,0,0,0,1.86,22.17h0A122.18,122.18,0,0,0,381,102.39a121.43,121.43,0,0,0,67,20.14Z"/></svg>',
FaLinkedin: '<svg fill="currentColor" viewBox="0 0 448 512" class="w-8 h-8"><path d="M100.28 448H7.4V148.9h92.88zM53.79 108.1C24.09 108.1 0 83.5 0 53.8a53.79 53.79 0 0 1 107.58 0c0 29.7-24.1 54.3-53.79 54.3zM447.9 448h-92.68V302.4c0-34.7-.7-79.2-48.29-79.2-48.29 0-55.69 37.7-55.69 76.7V448h-92.78V148.9h89.08v40.8h1.3c12.4-23.5 42.69-48.3 87.83-48.3 93.97 0 111.28 61.9 111.28 142.3V448z"/></svg>',
};
const COLOR_MAP: Record<string, string> = {
FaFacebook: 'text-[#1877F2]',
FaDiscord: 'text-[#5865F2]',
FaGithub: 'text-[#000000]',
FaInstagram: 'text-[#E4405F]',
FaTiktok: 'text-[#000000]',
FaLinkedin: 'text-[#0A66C2]',
};
---
<section id="community" class="w-full py-20 md:py-28">
<div class="container">
<div class="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<h2 class="text-3xl font-semibold tracking-tight md:text-4xl">
Bergabung dengan Komunitas Kami di
<span class="block mt-2 text-primary-500">Berbagai Platform</span>
</h2>
<p class="max-w-[600px] mx-auto text-gray-600 md:text-lg/relaxed mt-4">
Terhubung dengan sesama developer di komunitas kami
</p>
</div>
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{socials.map((community: any, index: number) => {
const iconColor = COLOR_MAP[community.icon] || 'text-primary-500';
const iconSvg = ICON_SVG[community.icon] || '';
return (
<div
class="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={`animation-delay: ${index * 80}ms; animation-fill-mode: both;`}
>
<div class="flex flex-col items-start gap-5">
<div class="flex items-center gap-4">
<div class={iconColor} set:html={iconSvg} />
<h3 class="text-xl font-semibold text-gray-900">{community.name}</h3>
</div>
<p class="text-gray-600 text-sm/relaxed">{community.description}</p>
<a
href={community.link}
class="inline-flex items-center gap-2 mt-2 text-sm font-medium transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<span>Jelajahi Komunitas</span>
<svg fill="currentColor" viewBox="0 0 448 512" class="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1"><path d="M438.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L338.8 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l306.7 0L233.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>
</a>
</div>
</div>
);
})}
</div>
</div>
</section>
+80
View File
@@ -0,0 +1,80 @@
---
import navigations from '../data/navigations.json';
import socials from '../data/socials.json';
const year = new Date().getFullYear();
---
<footer class="w-full border-t bg-background py-12 md:py-16">
<div class="container px-4 md:px-6">
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<div class="space-y-4">
<div class="flex items-center gap-2">
<img src="/logo.webp" alt="IMPHNEN" class="w-28 h-auto" />
</div>
<p class="text-sm text-muted-foreground">
Ingin Menjadi Programmer Handal Namun Enggan Ngoding
</p>
<div class="flex space-x-4">
<a href="https://fb.com/groups/programmerhandal" class="text-muted-foreground hover:text-foreground" target="_blank" rel="noopener noreferrer">
<svg class="size-6" fill="currentColor" viewBox="0 0 320 512"><path d="M80 299.3V512H196V299.3h86.5l18-97.8H196V166.9c0-51.7 20.3-71.5 72.7-71.5c16.3 0 29.4.4 37 1.2V7.9C291.4 4 256.4 0 236.2 0C129.3 0 80 50.5 80 159.4v42.1H14v97.8H80z"/></svg>
</a>
<a href="https://discord.gg/imphnen" class="text-muted-foreground hover:text-foreground" target="_blank" rel="noopener noreferrer">
<svg class="size-6" fill="currentColor" viewBox="0 0 640 512"><path d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>
</a>
<a href="https://github.com/IMPHNEN/" class="text-muted-foreground hover:text-foreground" target="_blank" rel="noopener noreferrer">
<svg class="size-6" fill="currentColor" viewBox="0 0 496 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
</a>
<a href="https://www.instagram.com/imphnen.dev" class="text-muted-foreground hover:text-foreground" target="_blank" rel="noopener noreferrer">
<svg class="size-6" fill="currentColor" viewBox="0 0 448 512"><path d="M224.1 141c-63.6 0-114.9 51.3-114.9 114.9s51.3 114.9 114.9 114.9S339 319.5 339 255.9 287.7 141 224.1 141zm0 189.6c-41.1 0-74.7-33.5-74.7-74.7s33.5-74.7 74.7-74.7 74.7 33.5 74.7 74.7-33.6 74.7-74.7 74.7zm146.4-194.3c0 14.9-12 26.8-26.8 26.8-14.9 0-26.8-12-26.8-26.8s12-26.8 26.8-26.8 26.8 12 26.8 26.8zm76.1 27.2c-1.7-35.9-9.9-67.7-36.2-93.9-26.2-26.2-58-34.4-93.9-36.2-37-2.1-147.9-2.1-184.9 0-35.8 1.7-67.6 9.9-93.9 36.1s-34.4 58-36.2 93.9c-2.1 37-2.1 147.9 0 184.9 1.7 35.9 9.9 67.7 36.2 93.9s58 34.4 93.9 36.2c37 2.1 147.9 2.1 184.9 0 35.9-1.7 67.7-9.9 93.9-36.2 26.2-26.2 34.4-58 36.2-93.9 2.1-37 2.1-147.8 0-184.8zM398.8 388c-7.8 19.6-22.9 34.7-42.6 42.6-29.5 11.7-99.5 9-132.1 9s-102.7 2.6-132.1-9c-19.6-7.8-34.7-22.9-42.6-42.6-11.7-29.5-9-99.5-9-132.1s-2.6-102.7 9-132.1c7.8-19.6 22.9-34.7 42.6-42.6 29.5-11.7 99.5-9 132.1-9s102.7-2.6 132.1 9c19.6 7.8 34.7 22.9 42.6 42.6 11.7 29.5 9 99.5 9 132.1s2.7 102.7-9 132.1z"/></svg>
</a>
<a href="https://www.tiktok.com/@imphnen" class="text-muted-foreground hover:text-foreground" target="_blank" rel="noopener noreferrer">
<svg class="size-6" fill="currentColor" viewBox="0 0 448 512"><path d="M448,209.91a210.06,210.06,0,0,1-122.77-39.25V349.38A162.55,162.55,0,1,1,185,188.31V278.2a74.62,74.62,0,1,0,52.23,71.18V0l88,0a121.18,121.18,0,0,0,1.86,22.17h0A122.18,122.18,0,0,0,381,102.39a121.43,121.43,0,0,0,67,20.14Z"/></svg>
</a>
<a href="https://www.linkedin.com/company/imphnen" class="text-muted-foreground hover:text-foreground" target="_blank" rel="noopener noreferrer">
<svg class="size-6" fill="currentColor" viewBox="0 0 448 512"><path d="M100.28 448H7.4V148.9h92.88zM53.79 108.1C24.09 108.1 0 83.5 0 53.8a53.79 53.79 0 0 1 107.58 0c0 29.7-24.1 54.3-53.79 54.3zM447.9 448h-92.68V302.4c0-34.7-.7-79.2-48.29-79.2-48.29 0-55.69 37.7-55.69 76.7V448h-92.78V148.9h89.08v40.8h1.3c12.4-23.5 42.69-48.3 87.83-48.3 93.97 0 111.28 61.9 111.28 142.3V448z"/></svg>
</a>
</div>
</div>
<div class="space-y-4">
<h3 class="text-lg font-bold">Halaman</h3>
<ul class="space-y-2">
{navigations.map(({ link, title }: { link: string; title: string }) => (
<li>
<a href={link} class="text-sm text-muted-foreground hover:text-foreground">
{title}
</a>
</li>
))}
</ul>
</div>
<div class="space-y-4">
<h3 class="text-lg font-bold">Link</h3>
<ul class="space-y-2">
{socials.map(({ link, name }: { link: string; name: string }) => (
<li>
<a href={link} class="text-sm text-muted-foreground hover:text-foreground" target="_blank" rel="noopener noreferrer">
{name}
</a>
</li>
))}
</ul>
</div>
<div class="space-y-4">
<h3 class="text-lg font-bold">Partners</h3>
<ul class="space-y-2"></ul>
</div>
</div>
<div class="mt-8 border-t pt-8 text-center space-y-2">
<p class="text-xs text-muted-foreground">
&copy; {year} IMPHNEN - Ingin Menjadi Programmer Handal, Namun Enggan Ngoding. All rights reserved.
</p>
<p class="text-xs text-muted-foreground">
Powered by{' '}
<a href="https://ancikri.com" target="_blank" rel="noopener noreferrer" class="font-medium text-foreground hover:underline">
Ancikri
</a>
</p>
</div>
</div>
</footer>
+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>
);
}