Initial commit
This commit is contained in:
18
src/app/api/login/route.ts
Normal file
18
src/app/api/login/route.ts
Normal 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))
|
||||
}
|
||||
7
src/app/api/logout/route.ts
Normal file
7
src/app/api/logout/route.ts
Normal 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))
|
||||
}
|
||||
14
src/app/api/test/send/route.ts
Normal file
14
src/app/api/test/send/route.ts
Normal 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))
|
||||
}
|
||||
21
src/app/api/webhooks/whatsapp/route.ts
Normal file
21
src/app/api/webhooks/whatsapp/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
25
src/app/globals.css
Normal file
25
src/app/globals.css
Normal 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
15
src/app/layout.tsx
Normal 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
27
src/app/login/page.tsx
Normal 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
142
src/app/page.module.css
Normal 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
5
src/app/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function HomePage() {
|
||||
redirect('/login')
|
||||
}
|
||||
38
src/app/settings/page.tsx
Normal file
38
src/app/settings/page.tsx
Normal 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
50
src/app/test/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
src/app/webhook-logs/page.tsx
Normal file
31
src/app/webhook-logs/page.tsx
Normal 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
29
src/lib/auth.ts
Normal 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
31
src/lib/storage.ts
Normal 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
86
src/lib/whatsapp.ts
Normal 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.')
|
||||
}
|
||||
Reference in New Issue
Block a user