commit 65435dd16715d12c6a84cddd4bc30ff5c50c6adb Author: Wira Basalamah Date: Thu Apr 23 01:43:48 2026 +0700 Initial Kelola Bumi website diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..29b95fb --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +SMTP_HOST=mail.unified.co.id +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USER=mailer@unified.co.id +SMTP_PASS=your-password-here +CONTACT_TO_EMAIL=info@kelolabumi.com +CONTACT_CAPTCHA_SECRET=change-this-secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddfbe8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.next +node_modules +out +dist +.env* +!.env.example diff --git a/README.md b/README.md new file mode 100644 index 0000000..95fe59e --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Kelola Bumi Web + +Website perusahaan PT Kelola Bumi Nusantara berbasis Next.js. + +## Requirement + +- Node.js 20 atau lebih baru +- npm + +## Menjalankan lokal + +```bash +npm install +npm run dev +``` + +Server produksi lokal: + +```bash +npm run build +npm run start +``` + +## Environment + +Salin `.env.example` menjadi `.env.local` lalu isi nilainya. + +Variabel yang dipakai: + +- `SMTP_HOST` +- `SMTP_PORT` +- `SMTP_SECURE` +- `SMTP_USER` +- `SMTP_PASS` +- `CONTACT_TO_EMAIL` +- `CONTACT_CAPTCHA_SECRET` + +## Deploy ke server + +1. Clone repository di server. +2. Buat file `.env.local`. +3. Jalankan `npm install`. +4. Jalankan `npm run build`. +5. Jalankan `npm run start`. + +Untuk production, disarankan memakai process manager seperti PM2 atau systemd, dan reverse proxy seperti Nginx. + +## Catatan + +- Jangan commit `.env.local`. +- Folder `.next` dan `node_modules` tidak perlu di-upload ke git. diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..fd63fa9 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,235 @@ +import Image from "next/image"; +import aboutHeroImage from "../../images/file8.jpg"; +import historyImage from "../../images/kelor.jpg"; +import strategyImage from "../../images/alpukat.jpg"; + +const aboutNavItems = [ + { label: "Home", href: "/" }, + { label: "Tentang", href: "/about", active: true }, + { label: "Layanan", href: "/services" }, + { label: "Kontak", href: "/contact" } +]; + +const missions = [ + { + title: "1. Edukasi & Konsultasi", + description: + "Memberikan pendampingan teknis, perencanaan kebun, dan transfer pengetahuan yang relevan dengan kondisi lapangan." + }, + { + title: "2. Input & Pengembangan", + description: + "Mendukung penyediaan bibit, sarana produksi, dan penguatan sistem budidaya yang efektif dan bertanggung jawab." + }, + { + title: "3. Rantai Pasok", + description: + "Merapikan alur distribusi komoditas agar hasil usaha agraria terserap pasar dengan kualitas dan nilai yang lebih baik." + } +]; + +export default function AboutPage() { + return ( +
+
+
+ + Kelola Bumi Logo + + + + + + Konsultasi Sekarang + +
+
+ +
+
+ +
+
+
+

Sejarah Perjalanan

+

Berawal dari inisiatif lapangan, tumbuh menjadi mitra agraria.

+

+ Kelola Bumi dibangun dengan fokus pada pengelolaan usaha agraria + yang lebih terstruktur. Dari pengembangan komoditas hingga dukungan + distribusi, kami bertumbuh bersama kebutuhan pelaku usaha tani, + kebun, peternakan, dan perikanan di berbagai wilayah. +

+ +
+
+ 2015 + Tahun Berdiri +
+
+ 500+ + Inisiatif & Proyek +
+
+
+ +
+ Aktivitas pengembangan komoditas Kelola Bumi +
+
+
+ +
+
+
+

Visi & Misi

+

Landasan kerja untuk pertumbuhan agribisnis yang sehat.

+
+ +
+
+ Visi +

+ Menjadi perusahaan agrikultur yang dipercaya untuk menghubungkan + potensi lahan, komoditas, dan pasar secara berkelanjutan. +

+

+ Kami ingin menghadirkan sistem kerja agraria yang modern namun + tetap berpijak pada realitas lapangan dan nilai kemitraan jangka + panjang. +

+
+ +
+

Misi Strategis

+

Langkah nyata untuk sektor agraria yang lebih tangguh.

+

+ Pendekatan kami dirancang agar konsultasi, input, dan distribusi + berjalan sebagai satu sistem yang saling menguatkan. +

+
+ + {missions.map((mission, index) => ( +
+
{String(index + 1).padStart(2, "0")}
+

{mission.title}

+

{mission.description}

+
+ ))} +
+
+
+ +
+
+
+

Cara Kami Bekerja

+

Strategi yang dekat dengan kebutuhan riil di lapangan.

+

+ Setiap proyek kami awali dengan pemahaman konteks: komoditas, + kondisi lahan, kesiapan pasokan, dan target pasar. Dari sana, + solusi dibangun agar lebih presisi dan dapat dijalankan. +

+ + Hubungi Kami + +
+ +
+
+ Komoditas unggulan Kelola Bumi +
+
+

Kolaborasi, presisi, dan kesinambungan.

+

+ Kami memadukan kualitas input, disiplin pengelolaan, dan + orientasi pasar agar setiap tahapan usaha agraria bergerak lebih + efisien dan bernilai. +

+
+
+
+
+ +
+
+
+

Bertumbuh Bersama

+

Siap mendiskusikan kebutuhan agrikultur Anda?

+

+ Kami siap mendampingi langkah Anda melalui konsultasi, penguatan + operasional, dan strategi pengembangan komoditas yang lebih terarah. +

+
+ + Lihat Layanan Kami + +
+
+ +
+
+
+
Kelola Bumi
+

© 2026 PT Kelola Bumi Nusantara. An Agricultural Company.

+
+
+ Home + Tentang + Layanan + Kontak +
+
+
+
+ ); +} diff --git a/app/api/contact/captcha/route.ts b/app/api/contact/captcha/route.ts new file mode 100644 index 0000000..6fedd48 --- /dev/null +++ b/app/api/contact/captcha/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { generateCaptchaChallenge } from "../../../../lib/contact-captcha"; + +export async function GET() { + const challenge = generateCaptchaChallenge(); + return NextResponse.json(challenge); +} diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..6251629 --- /dev/null +++ b/app/api/contact/route.ts @@ -0,0 +1,147 @@ +import nodemailer from "nodemailer"; +import { NextRequest, NextResponse } from "next/server"; +import { verifyCaptchaToken } from "../../../lib/contact-captcha"; + +const WINDOW_MS = 30 * 60 * 1000; +const MAX_REQUESTS = 5; +const MIN_FILL_MS = 3000; + +type RateLimitEntry = { + count: number; + resetAt: number; +}; + +const rateLimitStore = globalThis as typeof globalThis & { + __contactRateLimit?: Map; +}; + +const requests = rateLimitStore.__contactRateLimit ?? new Map(); +rateLimitStore.__contactRateLimit = requests; + +function getClientIp(request: NextRequest) { + const forwardedFor = request.headers.get("x-forwarded-for"); + if (forwardedFor) { + return forwardedFor.split(",")[0]?.trim() ?? "unknown"; + } + + return request.headers.get("x-real-ip") ?? "unknown"; +} + +function isRateLimited(ip: string) { + const now = Date.now(); + const existing = requests.get(ip); + + if (!existing || existing.resetAt <= now) { + requests.set(ip, { count: 1, resetAt: now + WINDOW_MS }); + return false; + } + + if (existing.count >= MAX_REQUESTS) { + return true; + } + + existing.count += 1; + requests.set(ip, existing); + return false; +} + +export async function POST(request: NextRequest) { + const ip = getClientIp(request); + + if (isRateLimited(ip)) { + return NextResponse.json( + { message: "Terlalu banyak percobaan. Silakan coba lagi beberapa saat." }, + { status: 429 } + ); + } + + const body = (await request.json()) as { + fullName?: string; + email?: string; + subject?: string; + message?: string; + website?: string; + startedAt?: string; + captchaAnswer?: string; + captchaToken?: string; + }; + + if (body.website) { + return NextResponse.json({ message: "Permintaan ditolak." }, { status: 400 }); + } + + const startedAt = Number(body.startedAt ?? 0); + if (!startedAt || Date.now() - startedAt < MIN_FILL_MS) { + return NextResponse.json( + { message: "Form dikirim terlalu cepat. Silakan isi kembali dengan benar." }, + { status: 400 } + ); + } + + const fullName = body.fullName?.trim(); + const email = body.email?.trim(); + const subject = body.subject?.trim(); + const message = body.message?.trim(); + const captchaAnswer = body.captchaAnswer?.trim(); + const captchaToken = body.captchaToken?.trim(); + + if (!fullName || !email || !subject || !message) { + return NextResponse.json( + { message: "Semua field wajib diisi sebelum mengirim." }, + { status: 400 } + ); + } + + if (!captchaAnswer || !captchaToken || !verifyCaptchaToken(captchaToken, captchaAnswer)) { + return NextResponse.json( + { message: "Captcha tidak valid atau sudah kedaluwarsa." }, + { status: 400 } + ); + } + + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT ?? 465), + secure: process.env.SMTP_SECURE !== "false", + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS + } + }); + + try { + await transporter.sendMail({ + from: `"Kelola Bumi Contact Form" <${process.env.SMTP_USER}>`, + to: process.env.CONTACT_TO_EMAIL, + replyTo: email, + subject: `[Kelola Bumi] ${subject}`, + text: [ + `Nama: ${fullName}`, + `Email: ${email}`, + `Subjek: ${subject}`, + "", + "Pesan:", + message + ].join("\n"), + html: ` +
+

Pesan Baru dari Form Kontak Kelola Bumi

+

Nama: ${fullName}

+

Email: ${email}

+

Subjek: ${subject}

+

Pesan:

+

${message.replace(/\n/g, "
")}

+
+ ` + }); + + return NextResponse.json({ + message: "Pesan berhasil dikirim. Tim kami akan menghubungi Anda." + }); + } catch { + return NextResponse.json( + { message: "Email gagal dikirim. Periksa konfigurasi mailer." }, + { status: 500 } + ); + } +} diff --git a/app/contact/contact-form.tsx b/app/contact/contact-form.tsx new file mode 100644 index 0000000..5f8f289 --- /dev/null +++ b/app/contact/contact-form.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; + +type SubmitState = + | { type: "idle"; message: string } + | { type: "success"; message: string } + | { type: "error"; message: string }; + +const initialState: SubmitState = { + type: "idle", + message: "" +}; + +export function ContactForm() { + const [submitState, setSubmitState] = useState(initialState); + const [isSubmitting, setIsSubmitting] = useState(false); + const [captchaPrompt, setCaptchaPrompt] = useState(""); + const [captchaToken, setCaptchaToken] = useState(""); + const [captchaLoading, setCaptchaLoading] = useState(true); + const startedAt = useMemo(() => Date.now().toString(), []); + + async function loadCaptcha() { + setCaptchaLoading(true); + + try { + const response = await fetch("/api/contact/captcha", { + cache: "no-store" + }); + const data = (await response.json()) as { prompt?: string; token?: string }; + + setCaptchaPrompt(data.prompt ?? ""); + setCaptchaToken(data.token ?? ""); + } finally { + setCaptchaLoading(false); + } + } + + useEffect(() => { + void loadCaptcha(); + }, []); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setIsSubmitting(true); + setSubmitState(initialState); + + const form = event.currentTarget; + const formData = new FormData(form); + + try { + const response = await fetch("/api/contact", { + method: "POST", + body: JSON.stringify({ + fullName: formData.get("fullName"), + email: formData.get("email"), + subject: formData.get("subject"), + message: formData.get("message"), + website: formData.get("website"), + startedAt: formData.get("startedAt"), + captchaAnswer: formData.get("captchaAnswer"), + captchaToken + }), + headers: { + "Content-Type": "application/json" + } + }); + + const data = (await response.json()) as { message?: string }; + + if (!response.ok) { + setSubmitState({ + type: "error", + message: data.message ?? "Pesan gagal dikirim. Silakan coba lagi." + }); + await loadCaptcha(); + return; + } + + form.reset(); + await loadCaptcha(); + setSubmitState({ + type: "success", + message: data.message ?? "Pesan berhasil dikirim." + }); + } catch { + setSubmitState({ + type: "error", + message: "Terjadi kendala jaringan saat mengirim pesan." + }); + await loadCaptcha(); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ + + +
+ + +
+ + + +