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:
co-authored by
Claude Opus 4.6
parent
0ade3c3e50
commit
95bf09c2fa
@@ -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$"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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/**/*'],
|
||||
},
|
||||
];
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare module '*.svg' {
|
||||
const content: any;
|
||||
export const ReactComponent: any;
|
||||
export default content;
|
||||
}
|
||||
Vendored
-6
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,24 +1,12 @@
|
||||
{
|
||||
"name": "landing",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/landing",
|
||||
"sourceRoot": "apps/landing/src",
|
||||
"projectType": "application",
|
||||
"tags": [],
|
||||
"// targets": "to see all targets run: nx show project landing --web",
|
||||
"targets": {
|
||||
"serve": {
|
||||
"executor": "@nx/next:server",
|
||||
"options": {
|
||||
"buildTarget": "landing:build",
|
||||
"dev": true
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "@nx/next:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/landing"
|
||||
}
|
||||
}
|
||||
"dev": { "command": "cd apps/landing && npx astro dev" },
|
||||
"build": { "command": "cd apps/landing && npx astro build" },
|
||||
"preview": { "command": "cd apps/landing && npx astro preview" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import SOCIALS from '@/data/socials.json';
|
||||
import {
|
||||
FaArrowRight,
|
||||
FaDiscord,
|
||||
FaGithub,
|
||||
FaFacebook,
|
||||
FaInstagram,
|
||||
FaLinkedin,
|
||||
FaTiktok,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
const ICON_MAP: Record<string, typeof FaArrowRight> = {
|
||||
FaFacebook, FaDiscord, FaGithub, FaInstagram, FaTiktok, FaLinkedin,
|
||||
};
|
||||
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
FaFacebook: 'text-[#1877F2]',
|
||||
FaDiscord: 'text-[#5865F2]',
|
||||
FaGithub: 'text-[#000000]',
|
||||
FaInstagram: 'text-[#E4405F]',
|
||||
FaTiktok: 'text-[#000000]',
|
||||
FaLinkedin: 'text-[#0A66C2]',
|
||||
};
|
||||
|
||||
export function CommunitySection() {
|
||||
return (
|
||||
<section id="community" className="w-full py-20 md:py-28">
|
||||
<div className="container">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
|
||||
Bergabung dengan Komunitas Kami di
|
||||
<span className="block mt-2 text-primary-500">Berbagai Platform</span>
|
||||
</h2>
|
||||
<p className="max-w-[600px] mx-auto text-gray-600 md:text-lg/relaxed mt-4">
|
||||
Terhubung dengan sesama developer di komunitas kami
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{SOCIALS.map((community, index) => {
|
||||
const IconComponent = ICON_MAP[community.icon] || FaArrowRight;
|
||||
const iconColor = COLOR_MAP[community.icon] || 'text-primary-500';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative p-8 bg-white border border-gray-200 rounded-xl hover:border-gray-300 transition-all duration-300 animate-[fadeInUp_0.4s_ease-out]"
|
||||
style={{ animationDelay: `${index * 80}ms`, animationFillMode: 'both' }}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<IconComponent className={`w-8 h-8 ${iconColor} transition-colors`} />
|
||||
<h3 className="text-xl font-semibold text-gray-900">{community.name}</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm/relaxed">{community.description}</p>
|
||||
<a
|
||||
href={community.link}
|
||||
className="inline-flex items-center gap-2 mt-2 text-sm font-medium transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>Jelajahi Komunitas</span>
|
||||
<FaArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,109 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import HERO_CONTENT from '@/data/hero-content.json';
|
||||
import HERO_STATS from '@/data/hero-stats.json';
|
||||
import { Button } from '@components';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export function HeroSection() {
|
||||
const router = useRouter();
|
||||
const { communityLabel, headingLine1, headingLine2, description, buttons } =
|
||||
HERO_CONTENT;
|
||||
|
||||
return (
|
||||
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
|
||||
<div className="absolute top-1/4 -left-20 w-80 h-80 rounded-full bg-gradient-to-r from-primary/20 to-blue-400/20 blur-3xl opacity-60" />
|
||||
<div className="absolute bottom-1/3 -right-20 w-80 h-80 rounded-full bg-gradient-to-r from-blue-400/20 to-primary/20 blur-3xl opacity-60" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] bg-[size:40px_40px]" />
|
||||
</div>
|
||||
|
||||
<div className="container px-4 md:px-6 relative">
|
||||
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
|
||||
<div className="flex flex-col justify-center space-y-8 animate-[fadeInUp_0.5s_ease-out]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm">
|
||||
{communityLabel}
|
||||
</span>
|
||||
<a
|
||||
href="https://ancikri.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center rounded-full border px-3 py-1 text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
Powered By Ancikri
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
||||
{headingLine1}{' '}
|
||||
<span className="bg-clip-text bg-gradient-to-r text-primary-500">
|
||||
{headingLine2}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="max-w-[600px] text-muted-foreground md:text-xl">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||
<Button size="lg" onClick={() => router.push(buttons.joinUrl)}>
|
||||
{buttons.join}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="bordered"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => router.push(buttons.exploreUrl)}
|
||||
>
|
||||
{buttons.explore}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-6 sm:gap-8">
|
||||
{HERO_STATS.map(({ value, label }, i) => (
|
||||
<Fragment key={i}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-2xl font-bold bg-clip-text text-primary-500">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
{i < HERO_STATS.length - 1 && (
|
||||
<div className="hidden sm:block h-10 border-r border-border mx-4" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full lg:w-auto mx-auto lg:ml-auto animate-[fadeIn_0.5s_ease-out_0.2s_both]">
|
||||
<Image
|
||||
src="/logo.webp"
|
||||
alt="logo"
|
||||
width={600}
|
||||
height={500}
|
||||
className="w-full h-auto object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { CommunitySection } from './_components/community-section';
|
||||
import { CTASection } from './_components/cta-section';
|
||||
import { HeroSection } from './_components/hero-section';
|
||||
import { TestimonialSection } from './_components/testimonial-section';
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<CommunitySection />
|
||||
<TestimonialSection />
|
||||
<CTASection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import NAVIGATIONS from '@/data/navigations.json';
|
||||
import SOCIALS from '@/data/socials.json';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
FaDiscord,
|
||||
FaFacebook,
|
||||
FaGithub,
|
||||
FaInstagram,
|
||||
FaLinkedinIn,
|
||||
FaTiktok,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full border-t bg-background py-12 md:py-16">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogoSimple />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ingin Menjadi Programmer Handal Namun Enggan Ngoding
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
href="https://fb.com/groups/programmerhandal"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaFacebook className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://discord.gg/imphnen"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaDiscord className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/IMPHNEN/"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaGithub className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.instagram.com/imphnen.dev"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaInstagram className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.tiktok.com/@imphnen"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaTiktok className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.linkedin.com/company/imphnen"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaLinkedinIn className="size-6" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Halaman</h3>
|
||||
<ul className="space-y-2">
|
||||
{NAVIGATIONS.map(({ link, title }) => (
|
||||
<li key={link}>
|
||||
<Link
|
||||
href={link}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Link</h3>
|
||||
<ul className="space-y-2">
|
||||
{SOCIALS.map(({ link, name }) => (
|
||||
<li key={name}>
|
||||
<Link
|
||||
href={link}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Partners</h3>
|
||||
<ul className="space-y-2"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t pt-8 text-center space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
|
||||
Handal, Namun Enggan Ngoding. All rights reserved.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Powered by{' '}
|
||||
<Link
|
||||
href="https://ancikri.com"
|
||||
target="_blank"
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
Ancikri
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import NAVIGATIONS from '@/data/navigations.json';
|
||||
import { cn } from '@utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LuMenu, LuX } from 'react-icons/lu';
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 w-full z-50 bg-background/70">
|
||||
<div className="container flex h-20 items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="relative overflow-hidden rounded z-50"
|
||||
aria-label="Home"
|
||||
>
|
||||
<LogoSimple className="w-24 md:w-28" />
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
{NAVIGATIONS.map(({ link, title }) => (
|
||||
<Link
|
||||
key={link}
|
||||
href={link}
|
||||
className={cn(
|
||||
'text-sm font-medium relative group px-1 py-2',
|
||||
pathname === link ? 'text-primary' : 'text-foreground/90'
|
||||
)}
|
||||
>
|
||||
<span className="transition-colors hover:text-primary block">
|
||||
{title}
|
||||
</span>
|
||||
{pathname === link && (
|
||||
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-primary-500" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
className="flex md:hidden relative z-50 p-2 rounded-lg hover:bg-muted transition-colors"
|
||||
aria-label={mobileMenuOpen ? 'Tutup menu' : 'Buka menu'}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<LuX className="size-6" />
|
||||
) : (
|
||||
<LuMenu className="size-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-60 bg-background flex flex-col"
|
||||
>
|
||||
<div className="container flex h-20 items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="relative overflow-hidden rounded"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<LogoSimple className="w-24" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="p-2 rounded-lg hover:bg-muted transition-colors"
|
||||
aria-label="Tutup menu"
|
||||
>
|
||||
<LuX className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<motion.nav
|
||||
className="flex-1 flex flex-col items-center justify-center gap-6 py-10"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{NAVIGATIONS.map(({ link, title }) => (
|
||||
<Link
|
||||
key={link}
|
||||
href={link}
|
||||
className={cn(
|
||||
'text-2xl font-medium py-2 px-6 rounded-lg transition-colors',
|
||||
pathname === link
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-foreground hover:bg-muted'
|
||||
)}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
))}
|
||||
</motion.nav>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@components';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LuMoon, LuSun } from 'react-icons/lu';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="focus-visible:ring-0 cursor-pointer"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<LuSun className="h-[1.2rem] w-[1.2rem]" />
|
||||
) : (
|
||||
<LuMoon className="h-[1.2rem] w-[1.2rem]" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { buttonVariants } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HiCalendar, HiClock, HiLocationMarker } from 'react-icons/hi';
|
||||
|
||||
interface ApiEvent {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
detail_link: string;
|
||||
price: number;
|
||||
is_online: boolean;
|
||||
is_deleted: boolean;
|
||||
location: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
data: ApiEvent[];
|
||||
meta: Record<string, unknown>;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Jakarta',
|
||||
});
|
||||
};
|
||||
|
||||
const getEventStatus = (endDate: string) => {
|
||||
const now = new Date();
|
||||
const end = new Date(endDate);
|
||||
return end > now ? 'upcoming' : 'past';
|
||||
};
|
||||
|
||||
export default function EventsPage() {
|
||||
const [events, setEvents] = useState<ApiEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://api.imphnen.dev/v1/landing/cms/events')
|
||||
.then((res) => res.json())
|
||||
.then((json: ApiResponse) => {
|
||||
setEvents(json.data?.filter((e) => !e.is_deleted) || []);
|
||||
})
|
||||
.catch(() => setEvents([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const sortedEvents = [...events].sort(
|
||||
(a, b) =>
|
||||
new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="w-8 h-8 border-3 border-gray-200 border-t-primary-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (sortedEvents.length === 0) {
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
<p className="text-center text-muted-foreground">
|
||||
Belum ada event tersedia.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
|
||||
<div className="col-span-full">
|
||||
<div className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden bg-card">
|
||||
<div className="p-8 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="bg-primary/20 text-primary text-xs px-2.5 py-1 rounded-full">
|
||||
Event Terbaru
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
getEventStatus(sortedEvents[0].end_date) === 'upcoming'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{getEventStatus(sortedEvents[0].end_date) === 'upcoming'
|
||||
? 'Upcoming'
|
||||
: 'Selesai'}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
sortedEvents[0].is_online
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
)}
|
||||
>
|
||||
{sortedEvents[0].is_online ? 'Online' : 'Onsite'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-4 text-foreground">
|
||||
{sortedEvents[0].name}
|
||||
</h2>
|
||||
<div className="space-y-3 mb-6 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<HiCalendar className="w-4 h-4" />
|
||||
<span>{formatDate(sortedEvents[0].start_date)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HiClock className="w-4 h-4" />
|
||||
<span>
|
||||
{formatTime(sortedEvents[0].start_date)} -{' '}
|
||||
{formatTime(sortedEvents[0].end_date)} WIB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HiLocationMarker className="w-4 h-4" />
|
||||
<span>
|
||||
{sortedEvents[0].is_online
|
||||
? 'Online'
|
||||
: sortedEvents[0].location}
|
||||
</span>
|
||||
</div>
|
||||
{sortedEvents[0].price > 0 && (
|
||||
<div className="mt-1 font-medium">
|
||||
Rp {sortedEvents[0].price.toLocaleString('id-ID')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 line-clamp-4">
|
||||
{sortedEvents[0].description}
|
||||
</p>
|
||||
<a
|
||||
href={sortedEvents[0].detail_link}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'bordered' }),
|
||||
'mt-auto w-full md:w-fit'
|
||||
)}
|
||||
>
|
||||
Lihat Detail
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedEvents.slice(1).map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 bg-card"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
getEventStatus(event.end_date) === 'upcoming'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{getEventStatus(event.end_date) === 'upcoming'
|
||||
? 'Upcoming'
|
||||
: 'Selesai'}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
event.is_online
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
)}
|
||||
>
|
||||
{event.is_online ? 'Online' : 'Onsite'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-3 text-foreground">
|
||||
{event.name}
|
||||
</h3>
|
||||
<div className="space-y-2 mb-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<HiCalendar className="w-4 h-4" />
|
||||
<span>{formatDate(event.start_date)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HiLocationMarker className="w-4 h-4" />
|
||||
<span>
|
||||
{event.is_online ? 'Online' : event.location}
|
||||
</span>
|
||||
</div>
|
||||
{event.price > 0 && (
|
||||
<div className="font-medium">
|
||||
Rp {event.price.toLocaleString('id-ID')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{event.description}
|
||||
</p>
|
||||
<a
|
||||
href={event.detail_link}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'bordered' }),
|
||||
'w-full text-sm'
|
||||
)}
|
||||
>
|
||||
Lihat Detail
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { buttonVariants } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HiOutlineCode, HiOutlineTrophy } from 'react-icons/hi';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface TeamItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
city: string;
|
||||
logo: string | null;
|
||||
banner: string | null;
|
||||
}
|
||||
|
||||
interface WinnerItem {
|
||||
id: string;
|
||||
team_id: string;
|
||||
team_name: string;
|
||||
rank: number;
|
||||
prize: string | null;
|
||||
}
|
||||
|
||||
export default function HackathonsPage() {
|
||||
const [teams, setTeams] = useState<TeamItem[]>([]);
|
||||
const [winners, setWinners] = useState<WinnerItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('https://api.imphnen.dev/v1/hackathon/teams/browse?per_page=20')
|
||||
.then((r) => r.json())
|
||||
.then((j) => j.data?.data || [])
|
||||
.catch(() => []),
|
||||
fetch('https://api.imphnen.dev/v1/hackathon/winners')
|
||||
.then((r) => r.json())
|
||||
.then((j) => j.data || [])
|
||||
.catch(() => []),
|
||||
]).then(([t, w]) => {
|
||||
setTeams(t);
|
||||
setWinners(w);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getWinnerRank = (teamId: string) => {
|
||||
const w = winners.find((x) => x.team_id === teamId);
|
||||
return w ? w.rank : null;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-3 border-gray-200 border-t-primary-500 rounded-full animate-spin" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (teams.length === 0) {
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-lg mb-2">No hackathon teams yet.</p>
|
||||
<a href="https://hackathon.imphnen.dev" className={cn(buttonVariants(), 'mt-4')}>
|
||||
Join Hackathon
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold mb-3 text-foreground">IMPHNEN Hackathon</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
Tim-tim yang berpartisipasi dalam hackathon IMPHNEN
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{winners.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold mb-6 text-foreground flex items-center gap-2">
|
||||
<HiOutlineTrophy className="text-amber-500" /> Pemenang
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{winners.sort((a, b) => a.rank - b.rank).map((w) => (
|
||||
<div key={w.id} className="bg-amber-50 border-2 border-amber-200 rounded-xl p-6 text-center">
|
||||
<div className="text-4xl mb-2">{w.rank === 1 ? '🥇' : w.rank === 2 ? '🥈' : '🥉'}</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{w.team_name}</h3>
|
||||
<p className="text-sm text-amber-700 mt-1">Juara {w.rank}</p>
|
||||
{w.prize && <p className="text-xs text-amber-600 mt-1">{w.prize}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="text-2xl font-bold mb-6 text-foreground">Semua Tim</h2>
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{ visible: { transition: { staggerChildren: 0.08 } } }}
|
||||
>
|
||||
{teams.map((team, idx) => {
|
||||
const rank = getWinnerRank(team.id);
|
||||
return (
|
||||
<motion.div
|
||||
key={team.id}
|
||||
className={cn(
|
||||
'rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 bg-card group overflow-hidden',
|
||||
rank ? 'ring-2 ring-amber-300' : ''
|
||||
)}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: idx * 0.06 }}
|
||||
>
|
||||
{team.banner ? (
|
||||
<div className="h-40 bg-muted relative overflow-hidden">
|
||||
<img src={team.banner} alt={team.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-40 bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center">
|
||||
<HiOutlineCode className="w-12 h-12 text-primary-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">{team.name}</h3>
|
||||
{rank && (
|
||||
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-full font-medium">
|
||||
Juara {rank}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{team.city && (
|
||||
<p className="text-xs text-muted-foreground mb-2">{team.city}</p>
|
||||
)}
|
||||
{team.description && (
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">{team.description}</p>
|
||||
)}
|
||||
<a
|
||||
href={`https://hackathon.imphnen.dev/teams/${team.id}`}
|
||||
target="_blank"
|
||||
className={cn(buttonVariants({ variant: 'bordered' }), 'w-full text-sm')}
|
||||
>
|
||||
Lihat Tim
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { poppinsFont } from '@/lib/fonts';
|
||||
import '@/styles/globals.css';
|
||||
import { cn } from '@utils';
|
||||
import { type Metadata } from 'next';
|
||||
import { Providers } from './_components/providers';
|
||||
import { Toaster } from './_components/toaster';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
|
||||
description: 'Komunitas belajar programming untuk semua level',
|
||||
openGraph: {
|
||||
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
|
||||
description: 'Komunitas belajar programming untuk semua level',
|
||||
url: 'https://imphnen.dev',
|
||||
images: [
|
||||
{
|
||||
url: 'https://imphnen.dev/imphnen-group-cover.webp',
|
||||
alt: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
|
||||
width: 2550,
|
||||
height: 945,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
|
||||
description: 'Komunitas belajar programming untuk semua level',
|
||||
images: ['https://imphnen.dev/imphnen-group-cover.webp'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="id" suppressHydrationWarning>
|
||||
<body className={cn(poppinsFont.className, 'antialiased')}>
|
||||
<Providers
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster richColors />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+23
-12
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
import Public from '../layouts/Public.astro';
|
||||
|
||||
interface ApiEvent {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
detail_link: string;
|
||||
price: number;
|
||||
is_online: boolean;
|
||||
is_deleted: boolean;
|
||||
location: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
let events: ApiEvent[] = [];
|
||||
try {
|
||||
const res = await fetch('https://api.imphnen.dev/v1/landing/cms/events');
|
||||
const json = await res.json();
|
||||
events = (json.data || []).filter((e: ApiEvent) => !e.is_deleted);
|
||||
} catch {
|
||||
events = [];
|
||||
}
|
||||
|
||||
const sortedEvents = [...events].sort(
|
||||
(a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
|
||||
);
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Jakarta',
|
||||
});
|
||||
}
|
||||
|
||||
function getEventStatus(endDate: string) {
|
||||
const now = new Date();
|
||||
const end = new Date(endDate);
|
||||
return end > now ? 'upcoming' : 'past';
|
||||
}
|
||||
---
|
||||
|
||||
<Public title="Events - IMPHNEN">
|
||||
<section class="min-h-screen bg-background container py-10">
|
||||
{sortedEvents.length === 0 ? (
|
||||
<p class="text-center text-muted-foreground">
|
||||
Belum ada event tersedia.
|
||||
</p>
|
||||
) : (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{/* Featured event */}
|
||||
<div class="col-span-full">
|
||||
<div class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden bg-card">
|
||||
<div class="p-8 flex flex-col">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="bg-primary/20 text-primary text-xs px-2.5 py-1 rounded-full">
|
||||
Event Terbaru
|
||||
</span>
|
||||
<span
|
||||
class:list={[
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
getEventStatus(sortedEvents[0].end_date) === 'upcoming'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
]}
|
||||
>
|
||||
{getEventStatus(sortedEvents[0].end_date) === 'upcoming' ? 'Upcoming' : 'Selesai'}
|
||||
</span>
|
||||
<span
|
||||
class:list={[
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
sortedEvents[0].is_online
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-green-100 text-green-700',
|
||||
]}
|
||||
>
|
||||
{sortedEvents[0].is_online ? 'Online' : 'Onsite'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-4 text-foreground">
|
||||
{sortedEvents[0].name}
|
||||
</h2>
|
||||
<div class="space-y-3 mb-6 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>{formatDate(sortedEvents[0].start_date)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>
|
||||
{formatTime(sortedEvents[0].start_date)} - {formatTime(sortedEvents[0].end_date)} WIB
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
<span>
|
||||
{sortedEvents[0].is_online ? 'Online' : sortedEvents[0].location}
|
||||
</span>
|
||||
</div>
|
||||
{sortedEvents[0].price > 0 && (
|
||||
<div class="mt-1 font-medium">
|
||||
Rp {sortedEvents[0].price.toLocaleString('id-ID')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-muted-foreground mb-6 line-clamp-4">
|
||||
{sortedEvents[0].description}
|
||||
</p>
|
||||
<a
|
||||
href={sortedEvents[0].detail_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-input bg-background px-6 py-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors mt-auto w-full md:w-fit"
|
||||
>
|
||||
Lihat Detail
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other events */}
|
||||
{sortedEvents.slice(1).map((event) => (
|
||||
<div class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 bg-card">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<span
|
||||
class:list={[
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
getEventStatus(event.end_date) === 'upcoming'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
]}
|
||||
>
|
||||
{getEventStatus(event.end_date) === 'upcoming' ? 'Upcoming' : 'Selesai'}
|
||||
</span>
|
||||
<span
|
||||
class:list={[
|
||||
'px-2 py-1 rounded-full text-xs',
|
||||
event.is_online
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-green-100 text-green-700',
|
||||
]}
|
||||
>
|
||||
{event.is_online ? 'Online' : 'Onsite'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-3 text-foreground">
|
||||
{event.name}
|
||||
</h3>
|
||||
<div class="space-y-2 mb-4 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>{formatDate(event.start_date)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
<span>{event.is_online ? 'Online' : event.location}</span>
|
||||
</div>
|
||||
{event.price > 0 && (
|
||||
<div class="font-medium">
|
||||
Rp {event.price.toLocaleString('id-ID')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{event.description}
|
||||
</p>
|
||||
<a
|
||||
href={event.detail_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors w-full"
|
||||
>
|
||||
Lihat Detail
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Public>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user