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",
|
"name": "landing",
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "apps/landing",
|
"sourceRoot": "apps/landing/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"// targets": "to see all targets run: nx show project landing --web",
|
|
||||||
"targets": {
|
"targets": {
|
||||||
"serve": {
|
"dev": { "command": "cd apps/landing && npx astro dev" },
|
||||||
"executor": "@nx/next:server",
|
"build": { "command": "cd apps/landing && npx astro build" },
|
||||||
"options": {
|
"preview": { "command": "cd apps/landing && npx astro preview" }
|
||||||
"buildTarget": "landing:build",
|
|
||||||
"dev": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"executor": "@nx/next:build",
|
|
||||||
"outputs": ["{options.outputPath}"],
|
|
||||||
"options": {
|
|
||||||
"outputPath": "dist/apps/landing"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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("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 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>
|
||||||
@@ -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">
|
||||||
|
© {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>
|
||||||
@@ -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 { useEffect, useState } from 'react';
|
||||||
import { FaQuoteLeft } from 'react-icons/fa';
|
import { FaQuoteLeft } from 'react-icons/fa';
|
||||||
|
|
||||||
@@ -39,18 +35,23 @@ function getInitial(name: string) {
|
|||||||
return name.charAt(0).toUpperCase();
|
return name.charAt(0).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestimonialSection() {
|
export default function TestimonialSection() {
|
||||||
const [testimonials, setTestimonials] = useState<Testimonial[]>([]);
|
const [testimonials, setTestimonials] = useState<Testimonial[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('https://api.imphnen.dev/v1/landing/cms/testimonials')
|
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) => {
|
.then((json) => {
|
||||||
setTestimonials(
|
setTestimonials(
|
||||||
(json.data as ApiTestimonial[])
|
(json.data as ApiTestimonial[])
|
||||||
.filter((t) => !t.is_deleted)
|
.filter((t) => !t.is_deleted)
|
||||||
.slice(0, 6)
|
.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([]));
|
.catch(() => setTestimonials([]));
|
||||||
@@ -71,15 +72,22 @@ export function TestimonialSection() {
|
|||||||
<div
|
<div
|
||||||
key={testimonial.id}
|
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]"
|
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 flex-col space-y-4">
|
||||||
<div className="flex items-center gap-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)}
|
{getInitial(testimonial.name)}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-sm text-gray-600">{testimonial.role}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,9 +101,12 @@ export function TestimonialSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<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
|
Tulis Testimonimu
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
|
||||||
@source "../../../../libs/ui/src/**/*.{ts,tsx}";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -127,3 +125,12 @@
|
|||||||
@apply bg-background text-foreground;
|
@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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,48 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"jsxImportSource": "react",
|
||||||
"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"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@components": ["../../libs/ui/src/atoms/index.ts"],
|
|
||||||
"@utils": ["../../libs/utils/src/tailwind-merge/index.ts"],
|
|
||||||
"@schemas": ["../../libs/service/src/schemas/index.ts"],
|
|
||||||
"@/*": ["src/*"],
|
"@/*": ["src/*"],
|
||||||
"@imphnen-frontend-service/service": ["../../libs/service/src/index.ts"],
|
"@data/*": ["src/data/*"]
|
||||||
"@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"]
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"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,7 +8,7 @@
|
|||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
let
|
let
|
||||||
npmDepsHash = "sha256-pzH4uRpuJWBglMFlQ9Q0Sjk689ds3syrJ4Stod21ARU=";
|
npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
|
||||||
mkViteApp = { pkgs, src }: { name, buildScript, envVars ? {} }:
|
mkViteApp = { pkgs, src }: { name, buildScript, envVars ? {} }:
|
||||||
pkgs.buildNpmPackage {
|
pkgs.buildNpmPackage {
|
||||||
@@ -43,79 +43,25 @@
|
|||||||
inherit src npmDepsHash;
|
inherit src npmDepsHash;
|
||||||
npmFlags = [ "--legacy-peer-deps" ];
|
npmFlags = [ "--legacy-peer-deps" ];
|
||||||
makeCacheWritable = true;
|
makeCacheWritable = true;
|
||||||
nativeBuildInputs = with pkgs; [ nodejs_22 python3 ];
|
nativeBuildInputs = with pkgs; [ nodejs_22 util-linux ];
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
export NX_DAEMON=false HOME=$TMPDIR CI=true NO_COLOR=1 TERM=dumb
|
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
|
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
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
mkdir -p $out/bin $out/share/landing
|
mkdir -p $out
|
||||||
cp -r dist/apps/landing/.next/standalone/* $out/share/landing/
|
cp -r dist/apps/landing/* $out/
|
||||||
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
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
dontNpmBuild = true;
|
dontNpmBuild = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
mkLandingModule = { config, lib, pkgs, ... }:
|
mkLandingModule = mkStaticAppModule "landing";
|
||||||
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 ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mkStaticAppModule = appName: { config, lib, pkgs, ... }:
|
mkStaticAppModule = appName: { config, lib, pkgs, ... }:
|
||||||
let cfg = config.services."imphnen-${appName}"; in {
|
let cfg = config.services."imphnen-${appName}"; in {
|
||||||
@@ -180,7 +126,7 @@
|
|||||||
}
|
}
|
||||||
) // {
|
) // {
|
||||||
nixosModules = {
|
nixosModules = {
|
||||||
landing = mkLandingModule;
|
landing = mkStaticAppModule "landing";
|
||||||
backoffice = mkStaticAppModule "backoffice";
|
backoffice = mkStaticAppModule "backoffice";
|
||||||
gacha = mkStaticAppModule "gacha";
|
gacha = mkStaticAppModule "gacha";
|
||||||
dimentorin = mkStaticAppModule "dimentorin";
|
dimentorin = mkStaticAppModule "dimentorin";
|
||||||
|
|||||||
Generated
+2803
-268
File diff suppressed because it is too large
Load Diff
@@ -37,15 +37,18 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.1.1",
|
||||||
|
"@astrojs/react": "^5.0.3",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@marsidev/react-turnstile": "^1.5.0",
|
"@marsidev/react-turnstile": "^1.5.0",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@redocly/ajv": "^8.18.1",
|
"@redocly/ajv": "^8.18.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
"@tanstack/react-store": "^0.9.3",
|
"@tanstack/react-store": "^0.9.3",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"astro": "^6.1.5",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user