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>
);
}
@@ -1,91 +0,0 @@
'use client';
import { LogoSimple } from '@/app/_components/logo';
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
import { FaArrowRight } from 'react-icons/fa';
export function CTASection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.2 });
return (
<section className="w-full py-20 md:py-32 bg-gradient-to-br from-primary-500 to-primary-600 relative overflow-hidden">
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `url("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")`,
}}
></div>
<div className="container relative" ref={ref}>
<div className="flex flex-col lg:flex-row items-center justify-between gap-12">
<div className="flex-1 text-center lg:text-left space-y-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
>
<h2 className="text-4xl md:text-5xl font-bold text-white leading-tight">
<span className="block">LET&apos;S GO</span>
<span className="block text-5xl md:text-6xl mt-2">
<span className="text-yellow-300">SAAT</span>
<span className="text-white">NYA</span>
</span>
<span className="block mt-2">KAMU JOIN!</span>
</h2>
</motion.div>
<motion.p
initial={{ opacity: 0 }}
animate={isInView ? { opacity: 1 } : {}}
transition={{ delay: 0.3 }}
className="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!
</motion.p>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={isInView ? { scale: 1, opacity: 1 } : {}}
transition={{ type: 'spring', stiffness: 100 }}
className="flex justify-center lg:justify-start"
>
<a
href="#community"
className="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>
<FaArrowRight className="w-5 h-5 transition-all group-hover:rotate-45" />
</a>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ delay: 0.2 }}
className="flex-1 max-w-xl"
>
<div className="relative p-8">
<div className="absolute inset-0 bg-white/10 rounded-3xl transform rotate-6"></div>
<div className="relative bg-white/5 rounded-3xl p-8 backdrop-blur-lg border border-white/10">
<div className="flex flex-col items-center gap-6 text-white">
<LogoSimple className="w-[240px]" />
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold">250.000+</h3>
<p className="text-lg">Programmer Sudah Bergabung</p>
</div>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</section>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
+145
View File
@@ -0,0 +1,145 @@
---
import Public from '../layouts/Public.astro';
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;
}
let teams: TeamItem[] = [];
let winners: WinnerItem[] = [];
try {
const [teamsRes, winnersRes] = await Promise.all([
fetch('https://api.imphnen.dev/v1/hackathon/teams/browse?per_page=20'),
fetch('https://api.imphnen.dev/v1/hackathon/winners'),
]);
const teamsJson = await teamsRes.json();
const winnersJson = await winnersRes.json();
teams = teamsJson.data?.data || [];
winners = winnersJson.data || [];
} catch {
teams = [];
winners = [];
}
function getWinnerRank(teamId: string): number | null {
const w = winners.find((x) => x.team_id === teamId);
return w ? w.rank : null;
}
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
---
<Public title="Hackathon - IMPHNEN">
<section class="min-h-screen bg-background container py-10">
{teams.length === 0 ? (
<div class="flex items-center justify-center min-h-[50vh]">
<div class="text-center">
<p class="text-muted-foreground text-lg mb-2">No hackathon teams yet.</p>
<a
href="https://hackathon.imphnen.dev"
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 mt-4"
>
Join Hackathon
</a>
</div>
</div>
) : (
<>
<div class="text-center mb-12">
<h1 class="text-4xl font-bold mb-3 text-foreground">IMPHNEN Hackathon</h1>
<p class="text-muted-foreground text-lg max-w-2xl mx-auto">
Tim-tim yang berpartisipasi dalam hackathon IMPHNEN
</p>
</div>
{sortedWinners.length > 0 && (
<div class="mb-12">
<h2 class="text-2xl font-bold mb-6 text-foreground flex items-center gap-2">
<svg class="w-6 h-6 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3l3.057-3L12 3.057 15.943 0 19 3l-3 7h-8L5 3zM12 14l-3-3h6l-3 3zm-5 4h10l1 3H6l1-3z" /></svg>
Pemenang
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{sortedWinners.map((w) => (
<div class="bg-amber-50 border-2 border-amber-200 rounded-xl p-6 text-center">
<div class="text-4xl mb-2">
{w.rank === 1 ? '\uD83E\uDD47' : w.rank === 2 ? '\uD83E\uDD48' : '\uD83E\uDD49'}
</div>
<h3 class="text-lg font-bold text-gray-900">{w.team_name}</h3>
<p class="text-sm text-amber-700 mt-1">Juara {w.rank}</p>
{w.prize && <p class="text-xs text-amber-600 mt-1">{w.prize}</p>}
</div>
))}
</div>
</div>
)}
<h2 class="text-2xl font-bold mb-6 text-foreground">Semua Tim</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{teams.map((team) => {
const rank = getWinnerRank(team.id);
return (
<div
class:list={[
'rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 bg-card group overflow-hidden',
rank ? 'ring-2 ring-amber-300' : '',
]}
>
{team.banner ? (
<div class="h-40 bg-muted relative overflow-hidden">
<img
src={team.banner}
alt={team.name}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
</div>
) : (
<div class="h-40 bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center">
<svg class="w-12 h-12 text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
</div>
)}
<div class="p-6">
<div class="flex items-start justify-between mb-2">
<h3 class="text-lg font-semibold text-foreground">{team.name}</h3>
{rank && (
<span class="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-full font-medium">
Juara {rank}
</span>
)}
</div>
{team.city && (
<p class="text-xs text-muted-foreground mb-2">{team.city}</p>
)}
{team.description && (
<p class="text-muted-foreground text-sm mb-4 line-clamp-3">{team.description}</p>
)}
<a
href={`https://hackathon.imphnen.dev/teams/${team.id}`}
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 Tim
</a>
</div>
</div>
);
})}
</div>
</>
)}
</section>
</Public>
+14
View File
@@ -0,0 +1,14 @@
---
import Public from '../layouts/Public.astro';
import HeroSection from '../components/HeroSection.astro';
import CommunitySection from '../components/CommunitySection.astro';
import TestimonialSection from '../components/TestimonialSection';
import CTASection from '../components/CTASection.astro';
---
<Public>
<HeroSection />
<CommunitySection />
<TestimonialSection client:visible />
<CTASection />
</Public>
+24
View File
@@ -0,0 +1,24 @@
---
import Public from '../layouts/Public.astro';
import RoadmapVote from '../components/RoadmapVote';
import RequestFeature from '../components/RequestFeature';
---
<Public title="Roadmap - IMPHNEN">
<div class="pt-4 md:pt-6 pb-56 bg-gray-50 min-h-screen mx-4 lg:mx-0">
<!-- Feature Request CTA -->
<div class="bg-primary-500 rounded-xl px-4 py-8 md:p-8 mb-8 text-center mx-auto max-w-7xl">
<div class="max-w-3xl mx-auto">
<h1 class="text-2xl md:text-3xl font-bold text-white mb-3">
Punya Ide Bagus untuk IMPHNEN?
</h1>
<p class="text-white/90 mb-6 text-lg text-balance">
Bagikan ide fitur yang kamu inginkan dan vote untuk membuatnya nyata!
</p>
<RequestFeature client:idle />
</div>
</div>
<RoadmapVote client:visible />
</div>
</Public>
+9
View File
@@ -0,0 +1,9 @@
---
import Public from '../layouts/Public.astro';
---
<Public title="Teams - IMPHNEN">
<div class="min-h-screen flex items-center justify-center">
<p class="text-muted-foreground">Coming soon.</p>
</div>
</Public>
@@ -0,0 +1,84 @@
---
import Public from '../../layouts/Public.astro';
interface ApiTestimonial {
id: number;
user_id: number;
user_fullname: string;
role: string;
content: string;
created_at: string;
is_deleted: boolean;
}
let testimonials: ApiTestimonial[] = [];
try {
const res = await fetch('https://api.imphnen.dev/v1/landing/cms/testimonials');
const json = await res.json();
testimonials = (json.data || []).filter((t: ApiTestimonial) => !t.is_deleted);
} catch {
testimonials = [];
}
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();
}
---
<Public title="Testimonial - IMPHNEN">
<div class="min-h-screen">
<div class="py-16 px-4 text-center border-b border-border">
<svg class="size-14 mx-auto my-4 text-foreground" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H4a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h.5a.5.5 0 0 1 0 1H4a2 2 0 0 1-2-2V8a3 3 0 0 1 3-3h.5V2H2zM9 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h.5a.5.5 0 0 1 0 1H13a2 2 0 0 1-2-2V8a3 3 0 0 1 3-3h.5V2H11z"/>
</svg>
<h1 class="text-4xl font-bold mb-4 text-foreground">Testimonial</h1>
<p class="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>
<a
href="/testimonials/submit"
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"
>
Tulis Testimonialmu
</a>
</div>
<div class="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 class="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex flex-col space-y-4">
<div class="flex items-center gap-4">
<div class={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}>
{getInitial(testimonial.user_fullname)}
</div>
<div>
<h4 class="font-semibold text-gray-900">
{testimonial.user_fullname}
</h4>
<p class="text-sm text-gray-600">{testimonial.role}</p>
</div>
</div>
<div class="text-gray-600 relative">
<svg class="text-primary-500/30 w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 512 512"><path d="M464 256h-80v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8c-88.4 0-160 71.6-160 160v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-288 0H96v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8C71.6 32 0 103.6 0 192v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48z"/></svg>
<p class="text-sm/relaxed">{testimonial.content}</p>
</div>
</div>
</div>
))}
</div>
</div>
</Public>
@@ -0,0 +1,9 @@
---
import Public from '../../layouts/Public.astro';
---
<Public title="Submit Testimonial - IMPHNEN">
<div class="min-h-screen flex items-center justify-center">
<p class="text-muted-foreground">Coming soon.</p>
</div>
</Public>
+9 -2
View File
@@ -1,6 +1,4 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@source "../../../../libs/ui/src/**/*.{ts,tsx}";
@custom-variant dark (&:is(.dark *));
@@ -127,3 +125,12 @@
@apply bg-background text-foreground;
}
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
+5 -41
View File
@@ -1,48 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "preserve",
"strict": true,
"noEmit": true,
"emitDeclarationOnly": false,
"esModuleInterop": true,
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@components": ["../../libs/ui/src/atoms/index.ts"],
"@utils": ["../../libs/utils/src/tailwind-merge/index.ts"],
"@schemas": ["../../libs/service/src/schemas/index.ts"],
"@/*": ["src/*"],
"@imphnen-frontend-service/service": ["../../libs/service/src/index.ts"],
"@imphnen-frontend-service/ui/atoms": ["../../libs/ui/src/atoms/index.ts"],
"@imphnen-frontend-service/ui/molecules": ["../../libs/ui/src/molecules/index.ts"],
"@imphnen-frontend-service/ui/organisms": ["../../libs/ui/src/organisms/index.ts"],
"@imphnen-frontend-service/utils": ["../../libs/utils/src/index.ts"]
"@data/*": ["src/data/*"]
}
},
"include": [
"**/*.js",
"**/*.jsx",
"**/*.ts",
"**/*.tsx",
"../../apps/landing/.next/types/**/*.ts",
"../../dist/apps/landing/.next/types/**/*.ts",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": ["node_modules", "jest.config.ts", "**/*.spec.ts", "**/*.test.ts"]
}
}
+8 -62
View File
@@ -8,7 +8,7 @@
outputs = { self, nixpkgs, flake-utils }:
let
npmDepsHash = "sha256-pzH4uRpuJWBglMFlQ9Q0Sjk689ds3syrJ4Stod21ARU=";
npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
mkViteApp = { pkgs, src }: { name, buildScript, envVars ? {} }:
pkgs.buildNpmPackage {
@@ -43,79 +43,25 @@
inherit src npmDepsHash;
npmFlags = [ "--legacy-peer-deps" ];
makeCacheWritable = true;
nativeBuildInputs = with pkgs; [ nodejs_22 python3 ];
nativeBuildInputs = with pkgs; [ nodejs_22 util-linux ];
buildPhase = ''
runHook preBuild
export NX_DAEMON=false HOME=$TMPDIR CI=true NO_COLOR=1 TERM=dumb
export NX_SKIP_NX_CACHE=true NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false NX_NATIVE=false
./node_modules/.bin/nx build landing --output-style=static
script -q -c "./node_modules/.bin/nx build landing --output-style=static" /dev/null || true
test -d dist/apps/landing
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/bin $out/share/landing
cp -r dist/apps/landing/.next/standalone/* $out/share/landing/
mkdir -p $out/share/landing/apps/landing/public
cp -r dist/apps/landing/public/* $out/share/landing/apps/landing/public/ || true
mkdir -p $out/share/landing/dist/apps/landing/.next/static
cp -r dist/apps/landing/.next/static/* $out/share/landing/dist/apps/landing/.next/static/
cat > $out/bin/imphnen-landing <<EOF
#!${pkgs.bash}/bin/bash
cd $out/share/landing
exec ${pkgs.nodejs_22}/bin/node --jitless apps/landing/server.js "\$@"
EOF
chmod +x $out/bin/imphnen-landing
mkdir -p $out
cp -r dist/apps/landing/* $out/
runHook postInstall
'';
dontNpmBuild = true;
};
mkLandingModule = { config, lib, pkgs, ... }:
let cfg = config.services.imphnen-landing; in {
options.services.imphnen-landing = {
enable = lib.mkEnableOption "Imphnen Landing Page";
port = lib.mkOption { type = lib.types.port; default = 3000; };
hostname = lib.mkOption { type = lib.types.str; default = "0.0.0.0"; };
package = lib.mkOption { type = lib.types.package; default = self.packages.${pkgs.system}.landing; };
openFirewall = lib.mkOption { type = lib.types.bool; default = false; };
environmentFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; };
};
config = lib.mkIf cfg.enable {
systemd.services.imphnen-landing = {
description = "Imphnen Landing Page";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = {
NODE_ENV = "production";
PORT = toString cfg.port;
HOSTNAME = cfg.hostname;
};
serviceConfig = {
Type = "simple";
ExecStart = "${cfg.package}/bin/imphnen-landing";
Restart = "on-failure";
RestartSec = "5s";
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
RestrictNamespaces = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
} // lib.optionalAttrs (cfg.environmentFile != null) {
EnvironmentFile = cfg.environmentFile;
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
};
mkLandingModule = mkStaticAppModule "landing";
mkStaticAppModule = appName: { config, lib, pkgs, ... }:
let cfg = config.services."imphnen-${appName}"; in {
@@ -180,7 +126,7 @@
}
) // {
nixosModules = {
landing = mkLandingModule;
landing = mkStaticAppModule "landing";
backoffice = mkStaticAppModule "backoffice";
gacha = mkStaticAppModule "gacha";
dimentorin = mkStaticAppModule "dimentorin";
+2803 -268
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -37,15 +37,18 @@
"private": true,
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@astrojs/react": "^5.0.3",
"@hookform/resolvers": "^5.2.2",
"@iconify/react": "^6.0.2",
"@marsidev/react-turnstile": "^1.5.0",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@redocly/ajv": "^8.18.1",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-store": "^0.9.3",
"@tanstack/react-table": "^8.21.3",
"astro": "^6.1.5",
"axios": "^1.14.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",