Initial commit

This commit is contained in:
2026-05-12 16:16:49 +07:00
commit ac2cfca335
32 changed files with 6613 additions and 0 deletions

View File

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { createSession } from '@/lib/auth'
export async function POST(request: Request) {
const formData = await request.formData()
const username = formData.get('username')?.toString() || ''
const password = formData.get('password')?.toString() || ''
if (
username === (process.env.WA_TEST_LOGIN_USERNAME || 'admin') &&
password === (process.env.WA_TEST_LOGIN_PASSWORD || 'admin123')
) {
await createSession()
return NextResponse.redirect(new URL('/settings', request.url))
}
return NextResponse.redirect(new URL('/login?error=Login%20gagal', request.url))
}

View File

@ -0,0 +1,7 @@
import { NextResponse } from 'next/server'
import { clearSession } from '@/lib/auth'
export async function POST(request: Request) {
await clearSession()
return NextResponse.redirect(new URL('/login', request.url))
}

View File

@ -0,0 +1,14 @@
import { NextResponse } from 'next/server'
import { sendTextMessage } from '@/lib/whatsapp'
export async function POST(request: Request) {
const formData = await request.formData()
const message = formData.get('message')?.toString() || ''
if (!message.trim()) {
return NextResponse.redirect(new URL('/test?status=Pesan%20kosong', request.url))
}
await sendTextMessage(process.env.WA_TEST_NUMBER || '', message)
return NextResponse.redirect(new URL('/test?status=Pesan%20berhasil%20dikirim', request.url))
}

View File

@ -0,0 +1,21 @@
import { NextResponse } from 'next/server'
import { handleWebhook } from '@/lib/whatsapp'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const mode = searchParams.get('hub.mode')
const token = searchParams.get('hub.verify_token')
const challenge = searchParams.get('hub.challenge')
if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
return new NextResponse(challenge || '', { status: 200 })
}
return new NextResponse('Verification failed', { status: 403 })
}
export async function POST(request: Request) {
const payload = await request.json()
await handleWebhook(payload)
return NextResponse.json({ ok: true })
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

25
src/app/globals.css Normal file
View File

@ -0,0 +1,25 @@
html, body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background: #f8fafc;
color: #0f172a;
}
* { box-sizing: border-box; }
a { color: inherit; text-decoration: none; }
.wrap { max-width: 1000px; margin: 40px auto; padding: 0 20px; }
.card { background: #fff; padding: 24px; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,.06); margin-top: 20px; }
.top { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.btn { color: #fff; background: #111827; padding: 10px 14px; border-radius: 10px; border: 0; cursor: pointer; display: inline-block; }
.input, .textarea { width: 100%; padding: 12px; border: 1px solid #d0d7e2; border-radius: 10px; }
.textarea { min-height: 110px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.item { background: #f8fafc; border: 1px solid #e2e8f0; padding: 14px; border-radius: 12px; }
.label { font-size: 12px; color: #64748b; text-transform: uppercase; }
.value { margin-top: 6px; word-break: break-all; }
.flash { background: #dcfce7; color: #166534; padding: 12px; border-radius: 10px; margin-bottom: 16px; }
.error { background: #fee2e2; color: #991b1b; padding: 10px; border-radius: 10px; margin-bottom: 16px; }
pre { white-space: pre-wrap; word-break: break-word; background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 12px; overflow: auto; }

15
src/app/layout.tsx Normal file
View File

@ -0,0 +1,15 @@
import './globals.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'WA Test NextJS',
description: 'Mini app test WhatsApp webhook',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="id">
<body>{children}</body>
</html>
)
}

27
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,27 @@
import { redirect } from 'next/navigation'
import { isAuthenticated } from '@/lib/auth'
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
if (await isAuthenticated()) {
redirect('/settings')
}
const params = await searchParams
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<div className="card" style={{ width: 360 }}>
<h2>Login WA Test</h2>
<p>Masuk pakai akun hardcoded dulu.</p>
{params.error ? <div className="error">{params.error}</div> : null}
<form method="POST" action="/api/login">
<label>Username</label>
<input className="input" type="text" name="username" required />
<label>Password</label>
<input className="input" type="password" name="password" required />
<button className="btn" type="submit" style={{ width: '100%' }}>Masuk</button>
</form>
</div>
</div>
)
}

142
src/app/page.module.css Normal file
View File

@ -0,0 +1,142 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
flex: 1;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

5
src/app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function HomePage() {
redirect('/login')
}

38
src/app/settings/page.tsx Normal file
View File

@ -0,0 +1,38 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { isAuthenticated } from '@/lib/auth'
export default async function SettingsPage() {
if (!await isAuthenticated()) {
redirect('/login')
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
return (
<div className="wrap">
<div className="top">
<div>
<h1>Settings</h1>
<p>Config hardcoded untuk test koneksi WhatsApp.</p>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<Link className="btn" href="/test">Halaman Test</Link>
<Link className="btn" href="/webhook-logs">Webhook Logs</Link>
<form method="POST" action="/api/logout"><button className="btn" type="submit">Logout</button></form>
</div>
</div>
<div className="card">
<div className="grid">
<div className="item"><div className="label">Webhook URL</div><div className="value">{appUrl}/api/webhooks/whatsapp</div></div>
<div className="item"><div className="label">Verify Token</div><div className="value">{process.env.WHATSAPP_VERIFY_TOKEN}</div></div>
<div className="item"><div className="label">Phone Number ID</div><div className="value">{process.env.WHATSAPP_PHONE_NUMBER_ID}</div></div>
<div className="item"><div className="label">Test Number</div><div className="value">{process.env.WA_TEST_NUMBER}</div></div>
<div className="item"><div className="label">Login Username</div><div className="value">{process.env.WA_TEST_LOGIN_USERNAME || 'admin'}</div></div>
<div className="item"><div className="label">Flow</div><div className="value">Kirim daftar, lalu kirim nama.</div></div>
</div>
</div>
</div>
)
}

50
src/app/test/page.tsx Normal file
View File

@ -0,0 +1,50 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { isAuthenticated } from '@/lib/auth'
import { readJsonFile } from '@/lib/storage'
export default async function TestPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
if (!await isAuthenticated()) {
redirect('/login')
}
const logs = readJsonFile<any[]>('webhook-logs.json', [])
const state = readJsonFile<Record<string, unknown>>('flow-state.json', {})
const params = await searchParams
return (
<div className="wrap">
<div className="top">
<div>
<h1>Halaman Test</h1>
<p>Nomor testing aktif: <strong>{process.env.WA_TEST_NUMBER}</strong></p>
<p>Flow: kirim <strong>daftar</strong>, lalu kirim nama.</p>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<Link className="btn" href="/settings">Settings</Link>
<Link className="btn" href="/webhook-logs">Webhook Logs</Link>
<form method="POST" action="/api/logout"><button className="btn" type="submit">Logout</button></form>
</div>
</div>
<div className="card">
{params.status ? <div className="flash">{params.status}</div> : null}
<form method="POST" action="/api/test/send">
<label>Kirim pesan ke nomor testing</label>
<textarea className="textarea" name="message" placeholder="Contoh: daftar" required />
<div style={{ marginTop: 12 }}><button className="btn" type="submit">Kirim Pesan</button></div>
</form>
</div>
<div className="card">
<h2>State Flow</h2>
<pre>{JSON.stringify(state, null, 2)}</pre>
</div>
<div className="card">
<h2>Ringkasan Log</h2>
<pre>{JSON.stringify(logs.slice(-10).reverse(), null, 2)}</pre>
</div>
</div>
)
}

View File

@ -0,0 +1,31 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { isAuthenticated } from '@/lib/auth'
import { readJsonFile } from '@/lib/storage'
export default async function WebhookLogsPage() {
if (!await isAuthenticated()) {
redirect('/login')
}
const logs = readJsonFile<any[]>('webhook-logs.json', [])
return (
<div className="wrap">
<div className="top">
<div>
<h1>Webhook Logs</h1>
<p>Semua log webhook masuk, outgoing, dan status ada di sini.</p>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<Link className="btn" href="/settings">Settings</Link>
<Link className="btn" href="/test">Halaman Test</Link>
</div>
</div>
<div className="card">
<pre>{JSON.stringify(logs.slice().reverse(), null, 2)}</pre>
</div>
</div>
)
}

29
src/lib/auth.ts Normal file
View File

@ -0,0 +1,29 @@
import crypto from 'crypto'
import { cookies } from 'next/headers'
const COOKIE_NAME = 'wa_test_session'
function sign(value: string) {
const secret = process.env.SESSION_SECRET || 'dev-secret'
return crypto.createHmac('sha256', secret).update(value).digest('hex')
}
export async function createSession() {
const value = 'logged_in'
const signed = `${value}.${sign(value)}`
const store = await cookies()
store.set(COOKIE_NAME, signed, { httpOnly: true, sameSite: 'lax', path: '/' })
}
export async function clearSession() {
const store = await cookies()
store.delete(COOKIE_NAME)
}
export async function isAuthenticated() {
const store = await cookies()
const raw = store.get(COOKIE_NAME)?.value
if (!raw) return false
const [value, signature] = raw.split('.')
return value === 'logged_in' && signature === sign(value)
}

31
src/lib/storage.ts Normal file
View File

@ -0,0 +1,31 @@
import fs from 'fs'
import path from 'path'
const dataDir = path.join(process.cwd(), 'data')
function ensureDir() {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
}
export function readJsonFile<T>(fileName: string, fallback: T): T {
ensureDir()
const filePath = path.join(dataDir, fileName)
if (!fs.existsSync(filePath)) return fallback
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T
} catch {
return fallback
}
}
export function writeJsonFile(fileName: string, data: unknown) {
ensureDir()
const filePath = path.join(dataDir, fileName)
fs.writeFileSync(filePath, JSON.stringify(data, null, 2))
}
export function appendLog(entry: unknown) {
const logs = readJsonFile<unknown[]>('webhook-logs.json', [])
logs.push(entry)
writeJsonFile('webhook-logs.json', logs)
}

86
src/lib/whatsapp.ts Normal file
View File

@ -0,0 +1,86 @@
import { appendLog, readJsonFile, writeJsonFile } from './storage'
type FlowState = Record<string, { step: string; name?: string }>
function normalizeNumber(number?: string | null) {
return (number || '').replace(/\D+/g, '')
}
export async function sendTextMessage(to: string, message: string) {
const apiVersion = process.env.WHATSAPP_API_VERSION || 'v23.0'
const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID || ''
const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || ''
const url = `https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to,
type: 'text',
text: { preview_url: false, body: message },
}),
})
const payload = await response.json().catch(async () => ({ raw: await response.text() }))
appendLog({ type: 'outgoing_message', to, message, response: payload, success: response.ok, created_at: new Date().toISOString() })
if (!response.ok) {
throw new Error('Gagal kirim pesan ke WhatsApp')
}
return payload
}
export async function handleWebhook(payload: any) {
appendLog({ type: 'webhook_received', payload, created_at: new Date().toISOString() })
for (const entry of payload.entry || []) {
for (const change of entry.changes || []) {
const value = change.value || {}
for (const message of value.messages || []) {
await handleIncomingMessage(message)
}
for (const status of value.statuses || []) {
appendLog({ type: 'message_status', status, created_at: new Date().toISOString() })
}
}
}
}
async function handleIncomingMessage(message: any) {
const from = message.from || ''
appendLog({ type: 'incoming_message', from, message, created_at: new Date().toISOString() })
if (normalizeNumber(from) !== normalizeNumber(process.env.WA_TEST_NUMBER)) {
appendLog({ type: 'ignored_message', reason: 'sender_not_test_number', from, created_at: new Date().toISOString() })
return
}
const body = (message.text?.body || '').trim()
const state = readJsonFile<FlowState>('flow-state.json', {})
const step = state[from]?.step || 'idle'
if (body.toLowerCase() === 'daftar' && step === 'idle') {
state[from] = { step: 'waiting_name' }
writeJsonFile('flow-state.json', state)
await sendTextMessage(from, 'Silakan kirim nama Anda.')
return
}
if (step === 'waiting_name' && body) {
state[from] = { step: 'completed', name: body }
writeJsonFile('flow-state.json', state)
await sendTextMessage(from, `Terima kasih ${body}, data testing diterima.`)
return
}
await sendTextMessage(from, 'Kirim "daftar" untuk mulai flow testing.')
}