Compare commits

..

3 Commits

Author SHA1 Message Date
bipproduction
0152229b96 feat: complete lid format handling and UI updates 2026-02-06 07:02:41 +08:00
bipproduction
32610ebfd1 Support @lid format in WhatsApp routes and relax number validation 2026-02-05 16:35:28 +08:00
bipproduction
98b134e72a Add prisma seed tests and refactor seed script 2026-02-05 16:33:43 +08:00
31 changed files with 2971 additions and 1970 deletions

View File

@@ -0,0 +1,35 @@
{
"name": "whatsapp-web",
"version": "1.0.0",
"description": "Automate WhatsApp messaging, groups, and media sharing via web interface",
"author": "Canifi",
"category": "communication",
"permissions": [
"browser",
"notifications",
"env-access"
],
"triggers": [
"whatsapp",
"whatsapp message",
"send whatsapp",
"whatsapp group",
"whatsapp status"
],
"authentication": {
"type": "qr-code",
"loginUrl": "https://web.whatsapp.com",
"sessionIndicator": "#app .app-wrapper-web"
},
"requiredEnvVars": [
"WHATSAPP_LINKED"
],
"capabilities": {
"messaging": true,
"groups": true,
"media": true,
"status": true,
"search": true,
"broadcast": true
}
}

View File

@@ -0,0 +1,142 @@
---
name: whatsapp-web
description: Enables Claude to send messages, manage groups, and handle WhatsApp communications through the web interface
version: 1.0.0
author: Canifi
category: communication
---
# WhatsApp Web Skill
## Overview
Automates WhatsApp Web interactions including messaging, group management, status updates, and media sharing through browser automation with QR code authentication.
## Quick Install
```bash
curl -sSL https://canifi.com/skills/whatsapp-web/install.sh | bash
```
Or manually:
```bash
cp -r skills/whatsapp-web ~/.canifi/skills/
```
## Setup
Configure via [canifi-env](https://canifi.com/setup/scripts):
```bash
# First, ensure canifi-env is installed:
# curl -sSL https://canifi.com/install.sh | bash
canifi-env set WHATSAPP_LINKED "true"
```
## Privacy & Authentication
**Your credentials, your choice.** Canifi LifeOS respects your privacy.
### Option 1: Manual Browser Login (Recommended)
If you prefer not to share credentials with Claude Code:
1. Complete the [Browser Automation Setup](/setup/automation) using CDP mode
2. Login to the service manually in the Playwright-controlled Chrome window
3. Claude will use your authenticated session without ever seeing your password
### Option 2: Environment Variables
If you're comfortable sharing credentials, you can store them locally:
```bash
canifi-env set SERVICE_EMAIL "your-email"
canifi-env set SERVICE_PASSWORD "your-password"
```
**Note**: Credentials stored in canifi-env are only accessible locally on your machine and are never transmitted.
## Capabilities
- Send and receive text messages
- Share images, videos, and documents
- Create and manage groups
- Update and view status/stories
- Search messages and contacts
- Star and archive chats
- Manage group settings and admins
- Handle broadcast lists
## Usage Examples
### Example 1: Send a Message
```
User: "Send a WhatsApp message to Sarah asking about dinner plans"
Claude: I'll send that message on WhatsApp.
- Navigate to web.whatsapp.com
- Search for Sarah in contacts
- Open chat and compose message
- Send: "Hey Sarah, what are the dinner plans for tonight?"
- Confirm message delivered (double checkmarks)
```
### Example 2: Create a Group
```
User: "Create a WhatsApp group called 'Weekend Trip' with John, Mike, and Lisa"
Claude: I'll create that group.
- Click New Group
- Add John, Mike, and Lisa
- Set group name "Weekend Trip"
- Add group icon if available
- Confirm group created
```
### Example 3: Share a File
```
User: "Send the project proposal PDF to the Work group on WhatsApp"
Claude: I'll share that document.
- Open Work group chat
- Click attach file
- Select and upload proposal PDF
- Add caption if needed
- Send file to group
```
### Example 4: Check Unread Messages
```
User: "Check my unread WhatsApp messages and summarize them"
Claude: I'll check your unread messages.
- Identify chats with unread indicators
- Open each unread chat
- Read and collect new messages
- Present summary of conversations
```
## Authentication Flow
1. Navigate to web.whatsapp.com via Playwright MCP
2. If QR code shown, notify user via iMessage to scan with phone
3. Wait for phone authentication (timeout: 60 seconds)
4. Verify chat list loads successfully
5. Maintain session via local storage
6. Re-authenticate if session expires
## Error Handling
- **QR Code Timeout**: iMessage reminder to scan QR code
- **Session Expired**: Notify user to re-link device
- **Phone Disconnected**: Alert user that phone must be online
- **Rate Limited**: Wait and implement backoff
- **Contact Not Found**: Search by phone number or name variations
- **Group Limit Reached**: Notify user of WhatsApp limits (1024 members)
- **Media Failed**: Check file size and format, retry upload
- **Connection Lost**: Wait for reconnection, notify if persistent
## Self-Improvement Instructions
When encountering new WhatsApp features:
1. Document new UI elements and chat patterns
2. Add support for new message types (polls, etc.)
3. Log successful group management patterns
4. Update for new WhatsApp Web features
## Notes
- WhatsApp Web requires phone to be connected to internet
- End-to-end encryption maintained through web interface
- Status/stories expire after 24 hours
- Broadcast lists have recipient limits
- Some features require WhatsApp Business
- Voice and video calls not supported via web automation
- Multi-device beta allows operation without phone online

1
.qwen/skills/whatsapp-web Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/whatsapp-web

92
GEMINI.md Normal file
View File

@@ -0,0 +1,92 @@
# GEMINI.md - Project Context & Instructions
## Project Overview
**wajs-server** is a full-stack WhatsApp integration platform built with Bun, ElysiaJS, and React. It provides a robust API and a web-based dashboard to manage WhatsApp sessions, send/receive messages, and integrate with external systems via webhooks.
### Main Technologies
- **Runtime**: [Bun](https://bun.sh/)
- **Backend Framework**: [ElysiaJS](https://elysiajs.com/)
- **Frontend Library**: [React](https://react.dev/) with [Mantine UI](https://mantine.dev/)
- **Database ORM**: [Prisma](https://www.prisma.io/) (Targeting PostgreSQL)
- **WhatsApp Integration**: [whatsapp-web.js](https://github.com/pedroslopez/whatsapp-web.js)
- **State Management/Data Fetching**: [SWR](https://swr.vercel.app/)
- **Routing**: [React Router](https://reactrouter.com/)
### Architecture
- **Server Entry Point**: `src/index.tsx` - Orchestrates the ElysiaJS server and serves the React frontend.
- **WhatsApp Service**: `src/server/lib/wa/wa_service.ts` - A singleton service managing the WhatsApp client lifecycle, event handling, and webhook dispatching.
- **API Routes**: Located in `src/server/routes/`, including:
- `wa_route.ts`: Core WhatsApp operations (send message, QR status, etc.).
- `webhook_route.ts`: CRUD for external webhooks.
- `auth_route.ts` & `apikey_route.ts`: Authentication and API key management.
- **Frontend**: Located in `src/pages/`, with routes defined in `src/AppRoutes.tsx`. It uses a dashboard layout (`src/pages/sq/dashboard/`).
- **Database Schema**: `prisma/schema.prisma` defines models for `User`, `ApiKey`, `WebHook`, `WaHook`, and `ChatFlows`.
---
## Building and Running
### Prerequisites
- [Bun](https://bun.sh/) installed.
- A PostgreSQL database instance.
### Setup
1. **Install dependencies**:
```bash
bun install
```
2. **Environment Configuration**:
Copy `.env.example` to `.env` and fill in the required variables:
- `DATABASE_URL`: PostgreSQL connection string.
- `JWT_SECRET`: Secret for JWT signing.
- `PORT`: Server port (default: 3000).
3. **Database Migration**:
```bash
bunx prisma migrate dev
```
### Development
Start the development server with hot-reloading:
```bash
bun dev
```
### Production
1. **Build the frontend**:
```bash
bun build
```
2. **Start the server**:
```bash
bun start
```
---
## Development Conventions
### Coding Style
- **TypeScript**: The project is strictly typed. Ensure new features are properly typed.
- **Functional Components**: React frontend uses functional components and hooks.
- **Elysia Patterns**: Use Elysia's `use()` plugin system for modular routes and middlewares.
### Key Workflows
- **WhatsApp Lifecycle**: The client uses `LocalAuth` for session persistence (stored in `.wwebjs_auth/`). In production, the client starts automatically on server boot.
- **Webhooks**: WhatsApp events are broadcast to all enabled webhooks defined in the database.
- **API Security**: Protected routes use the `apiAuth` middleware, which checks for either a valid JWT or a registered API key.
### Directory Structure
- `src/server/`: Backend logic.
- `src/pages/`: Frontend views.
- `src/components/`: Reusable React components.
- `prisma/`: Database configuration and migrations.
- `generated/prisma/`: Auto-generated Prisma client (output directory is customized in `schema.prisma`).
---
## Instructional Context for AI
- When modifying the WhatsApp logic, refer to `src/server/lib/wa/wa_service.ts`.
- When adding new API endpoints, register them in `src/index.tsx`.
- Frontend routing is managed in `src/AppRoutes.tsx`; follow the nested structure under `/sq/dashboard/` for new dashboard pages.
- Use `prisma` from `src/server/lib/prisma.ts` for database interactions.

49
__tests__/seed.test.ts Normal file
View File

@@ -0,0 +1,49 @@
import { mock } from "bun:test";
const mockPrisma = {
user: {
upsert: mock(async () => ({ id: "user-1", email: "admin@example.com" })),
},
apiKey: {
upsert: mock(async () => ({ id: "key-1" })),
},
webHook: {
findFirst: mock(async () => null as any),
create: mock(async () => ({ id: "webhook-1" })),
},
chatFlows: {
upsert: mock(async () => ({ id: "flow-1" })),
},
$disconnect: mock(async () => {}),
};
mock.module("@/server/lib/prisma", () => ({
prisma: mockPrisma,
}));
import { expect, test, describe } from "bun:test";
import { seed } from "../prisma/seed";
describe("Prisma Seed", () => {
test("seed function should populate default data", async () => {
const result = await seed();
expect(result.user.email).toBe("admin@example.com");
expect(mockPrisma.user.upsert).toHaveBeenCalled();
expect(mockPrisma.apiKey.upsert).toHaveBeenCalled();
expect(mockPrisma.webHook.findFirst).toHaveBeenCalled();
expect(mockPrisma.webHook.create).toHaveBeenCalled();
expect(mockPrisma.chatFlows.upsert).toHaveBeenCalled();
});
test("seed function should skip webhook creation if it already exists", async () => {
// Reset mocks
mockPrisma.webHook.create.mockClear();
mockPrisma.webHook.findFirst.mockResolvedValueOnce({ id: "existing-webhook" });
await seed();
expect(mockPrisma.webHook.findFirst).toHaveBeenCalled();
expect(mockPrisma.webHook.create).not.toHaveBeenCalled();
});
});

View File

@@ -11,6 +11,8 @@
"@elysiajs/swagger": "^1.3.1",
"@lglab/react-qr-code": "^1.4.9",
"@mantine/core": "^8.3.13",
"@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.13",
"@mantine/modals": "^8.3.13",
"@mantine/notifications": "^8.3.13",
@@ -49,7 +51,7 @@
"uuid": "^13.0.0",
"whatsapp-api-js": "^6.2.1",
"whatsapp-client-sdk": "^1.6.0",
"whatsapp-web.js": "github:pedroslopez/whatsapp-web.js#main",
"whatsapp-web.js": "^1.34.6",
"yaml": "^2.8.2",
},
"devDependencies": {
@@ -65,7 +67,7 @@
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
@@ -151,6 +153,10 @@
"@mantine/core": ["@mantine/core@8.3.13", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.13", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-ZgW4vqN4meaPyIMxzAufBvsgmJRfYZdTpsrAOcS8pWy7m9e8i685E7XsAxnwJCOIHudpvpvt+7Bx7VaIjEsYEw=="],
"@mantine/dates": ["@mantine/dates@8.3.14", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "8.3.14", "@mantine/hooks": "8.3.14", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-NdStRo2ZQ55MoMF5B9vjhpBpHRDHF1XA9Dkb1kKSdNuLlaFXKlvoaZxj/3LfNPpn7Nqlns78nWt4X8/cgC2YIg=="],
"@mantine/form": ["@mantine/form@8.3.14", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "klona": "^2.0.6" }, "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-LJUeab+oF+YzATrm/K03Z/QoVVYlaolWqLUZZj7XexNA4hS2/ycKyWT07YhGkdHTLXkf3DUtrg1sS77K7Oje8A=="],
"@mantine/hooks": ["@mantine/hooks@8.3.13", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-7YMbMW/tR9E8m/9DbBW01+3RNApm2mE/JbRKXf9s9+KxgbjQvq0FYGWV8Y4+Sjz48AO4vtWk2qBriUTgBMKAyg=="],
"@mantine/modals": ["@mantine/modals@8.3.13", "", { "peerDependencies": { "@mantine/core": "8.3.13", "@mantine/hooks": "8.3.13", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-5jIRJKEupQerHfPGcPHgQk+J6dGBO7spC66VgEZqCNRpbWhowCxBNGEW5LN1hZE9sLYBJg+z2MazPws4A1GohQ=="],
@@ -181,7 +187,7 @@
"@prisma/get-platform": ["@prisma/get-platform@6.19.2", "", { "dependencies": { "@prisma/debug": "6.19.2" } }, "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA=="],
"@puppeteer/browsers": ["@puppeteer/browsers@2.11.1", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-YmhAxs7XPuxN0j7LJloHpfD1ylhDuFmmwMvfy/+6nBSrETT2ycL53LrhgPtR+f+GcPSybQVuQ5inWWu5MrWCpA=="],
"@puppeteer/browsers": ["@puppeteer/browsers@2.12.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="],
@@ -279,7 +285,7 @@
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
"bare-fs": ["bare-fs@4.5.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw=="],
"bare-fs": ["bare-fs@4.5.3", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ=="],
"bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="],
@@ -325,7 +331,7 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chromium-bidi": ["chromium-bidi@12.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg=="],
"chromium-bidi": ["chromium-bidi@13.1.0", "", { "dependencies": { "mitt": "^3.0.1", "puppeteer": "^24.36.0", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-IdGNojX6S04+wgJOALzvkkIyLelhEGqI8xSctwiYJJGSi9T2eBjwAQW2UjBD/mCXv/rUkNlH2+h7jz+58vT74A=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
@@ -391,7 +397,7 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devtools-protocol": ["devtools-protocol@0.0.1534754", "", {}, "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ=="],
"devtools-protocol": ["devtools-protocol@0.0.1566079", "", {}, "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
@@ -449,6 +455,8 @@
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
@@ -549,6 +557,8 @@
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -685,9 +695,9 @@
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"puppeteer": ["puppeteer@24.35.0", "", { "dependencies": { "@puppeteer/browsers": "2.11.1", "chromium-bidi": "12.0.1", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1534754", "puppeteer-core": "24.35.0", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-sbjB5JnJ+3nwgSdRM/bqkFXqLxRz/vsz0GRIeTlCk+j+fGpqaF2dId9Qp25rXz9zfhqnN9s0krek1M/C2GDKtA=="],
"puppeteer": ["puppeteer@24.37.0", "", { "dependencies": { "@puppeteer/browsers": "2.12.0", "chromium-bidi": "13.1.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1566079", "puppeteer-core": "24.37.0", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-s1jHugVhPtQjiJE6wUyonj4VEGWF+mfRDASqPMPsXgKcjZX0GaznBmcT9nLQ7bBL90phuQUqO4jiV5vTecZg4g=="],
"puppeteer-core": ["puppeteer-core@24.35.0", "", { "dependencies": { "@puppeteer/browsers": "2.11.1", "chromium-bidi": "12.0.1", "debug": "^4.4.3", "devtools-protocol": "0.0.1534754", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.3.10", "ws": "^8.19.0" } }, "sha512-vt1zc2ME0kHBn7ZDOqLvgvrYD5bqNv5y2ZNXzYnCv8DEtZGw/zKhljlrGuImxptZ4rq+QI9dFGrUIYqG4/IQzA=="],
"puppeteer-core": ["puppeteer-core@24.37.0", "", { "dependencies": { "@puppeteer/browsers": "2.12.0", "chromium-bidi": "13.1.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.4.0", "ws": "^8.19.0" } }, "sha512-WoCBK36cBlbaxwuvPWhOp2+lR6O6ynHdDuvD8rEIkxPOPpUoMXSJuyiOWhHtexJBCLaMCAJk33QdYambvQl+og=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
@@ -841,7 +851,7 @@
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.3.10", "", {}, "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw=="],
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.0", "", {}, "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
@@ -849,7 +859,7 @@
"whatsapp-client-sdk": ["whatsapp-client-sdk@1.6.0", "", { "dependencies": { "axios": "^1.6.0", "form-data": "^4.0.0", "uuid": "^9.0.0" }, "peerDependencies": { "@supabase/supabase-js": "^2.0.0" }, "optionalPeers": ["@supabase/supabase-js"] }, "sha512-iAbdpv8tmw3RLPUxmw2ha8qbkKCuijbgl/HiDt4xpHgw3IWL3bB5o1SYX3qwuuEWwalXSNDiOu6W7lARJ8+XJw=="],
"whatsapp-web.js": ["whatsapp-web.js@github:pedroslopez/whatsapp-web.js#dd9df40", { "dependencies": { "@pedroslopez/moduleraid": "^5.0.2", "fluent-ffmpeg": "2.1.3", "mime": "^3.0.0", "node-fetch": "^2.6.9", "node-webpmux": "3.1.7", "puppeteer": "^24.31.0" }, "optionalDependencies": { "archiver": "^5.3.1", "fs-extra": "^10.1.0", "unzipper": "^0.10.11" } }, "pedroslopez-whatsapp-web.js-dd9df40"],
"whatsapp-web.js": ["whatsapp-web.js@1.34.6", "", { "dependencies": { "@pedroslopez/moduleraid": "^5.0.2", "fluent-ffmpeg": "2.1.3", "mime": "^3.0.0", "node-fetch": "^2.6.9", "node-webpmux": "3.1.7", "puppeteer": "^24.31.0" }, "optionalDependencies": { "archiver": "^5.3.1", "fs-extra": "^10.1.0", "unzipper": "^0.10.11" } }, "sha512-+zgLBqARcVfuCG7b80c7Gkt+4Yh8w+oDWx7lL2gTA6nlaykHBne7NwJ5yGe2r7O9IYraIzs6HiCzNGKfu9AUBg=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],

View File

@@ -196,7 +196,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "/Users/bip/Documents/projects/jenna/wajs-server/generated/prisma",
"value": "/Users/bip/Documents/projects/projects_2026/wajs-server/generated/prisma",
"fromEnvVar": null
},
"config": {
@@ -210,7 +210,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "/Users/bip/Documents/projects/jenna/wajs-server/prisma/schema.prisma",
"sourceFilePath": "/Users/bip/Documents/projects/projects_2026/wajs-server/prisma/schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {
@@ -224,7 +224,6 @@ const config = {
"db"
],
"activeProvider": "postgresql",
"postinstall": true,
"inlineDatasources": {
"db": {
"url": {

View File

@@ -197,7 +197,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "/Users/bip/Documents/projects/jenna/wajs-server/generated/prisma",
"value": "/Users/bip/Documents/projects/projects_2026/wajs-server/generated/prisma",
"fromEnvVar": null
},
"config": {
@@ -211,7 +211,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "/Users/bip/Documents/projects/jenna/wajs-server/prisma/schema.prisma",
"sourceFilePath": "/Users/bip/Documents/projects/projects_2026/wajs-server/prisma/schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {
@@ -225,7 +225,6 @@ const config = {
"db"
],
"activeProvider": "postgresql",
"postinstall": true,
"inlineDatasources": {
"db": {
"url": {

View File

@@ -196,7 +196,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "/Users/bip/Documents/projects/jenna/wajs-server/generated/prisma",
"value": "/Users/bip/Documents/projects/projects_2026/wajs-server/generated/prisma",
"fromEnvVar": null
},
"config": {
@@ -210,7 +210,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "/Users/bip/Documents/projects/jenna/wajs-server/prisma/schema.prisma",
"sourceFilePath": "/Users/bip/Documents/projects/projects_2026/wajs-server/prisma/schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {
@@ -224,7 +224,6 @@ const config = {
"db"
],
"activeProvider": "postgresql",
"postinstall": true,
"inlineDatasources": {
"db": {
"url": {

View File

@@ -7,7 +7,8 @@
"dev": "bun --hot src/index.tsx",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.tsx",
"seed": "bun prisma/seed.ts"
"seed": "bun prisma/seed.ts",
"test": "bun test"
},
"dependencies": {
"@elysiajs/cors": "^1.4.1",
@@ -16,6 +17,8 @@
"@elysiajs/swagger": "^1.3.1",
"@lglab/react-qr-code": "^1.4.9",
"@mantine/core": "^8.3.13",
"@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.13",
"@mantine/modals": "^8.3.13",
"@mantine/notifications": "^8.3.13",
@@ -54,7 +57,7 @@
"uuid": "^13.0.0",
"whatsapp-api-js": "^6.2.1",
"whatsapp-client-sdk": "^1.6.0",
"whatsapp-web.js": "github:pedroslopez/whatsapp-web.js#main",
"whatsapp-web.js": "^1.34.6",
"yaml": "^2.8.2"
},
"devDependencies": {

View File

@@ -1,32 +1,93 @@
import { prisma } from "@/server/lib/prisma";
const user = [
{
name: "wibu",
email: "wibu@bip.com",
password: "Production_123",
}
];
export async function seed() {
console.log("🌱 Starting seeding...");
; (async () => {
for (const u of user) {
await prisma.user.upsert({
where: { email: u.email },
create: u,
update: u,
// 1. Seed User from environment variables or defaults
const adminEmail = process.env.ADMIN_EMAIL || "admin@example.com";
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
const user = await prisma.user.upsert({
where: { email: adminEmail },
update: {},
create: {
name: "Administrator",
email: adminEmail,
password: adminPassword,
},
});
console.log(`✅ User seeded: ${user.email}`);
// 2. Seed Default API Key
const defaultKey = "wajs_sk_live_default_key_2026";
await prisma.apiKey.upsert({
where: { key: defaultKey },
update: {},
create: {
name: "Default API Key",
key: defaultKey,
userId: user.id,
description: "Auto-generated default API key",
},
});
console.log("✅ Default API Key seeded");
// 3. Seed Sample Webhook
const webhookUrl = "https://webhook.site/wajs-test";
const existingWebhook = await prisma.webHook.findFirst({
where: { url: webhookUrl },
});
if (!existingWebhook) {
await prisma.webHook.create({
data: {
name: "Sample Webhook",
url: webhookUrl,
description: "Test webhook for capturing events",
enabled: true,
method: "POST",
},
});
console.log("✅ Sample Webhook seeded");
} else {
console.log(" Sample Webhook already exists, skipping");
}
// 4. Seed Initial ChatFlow
const flowUrl = "initial-flow";
await prisma.chatFlows.upsert({
where: { flowUrl: flowUrl },
update: {},
create: {
flowUrl: flowUrl,
flows: {
nodes: [
{
id: "1",
type: "input",
data: { label: "Start" },
position: { x: 250, y: 5 },
},
],
edges: [],
},
active: true,
defaultFlow: "Main Flow",
},
});
console.log("✅ Initial ChatFlow seeded");
console.log("✨ Seeding completed successfully!");
return { user, defaultKey, webhookUrl, flowUrl };
}
if (import.meta.main) {
seed()
.catch((e) => {
console.error("❌ Seeding failed:", e);
process.exit(1);
})
console.log(`✅ User ${u.email} seeded successfully`)
}
})().catch((e) => {
console.error(e)
process.exit(1)
}).finally(() => {
console.log("✅ Seeding completed successfully ")
process.exit(0)
})
// use fix-code
.finally(async () => {
await prisma.$disconnect();
});
}

View File

@@ -1,5 +1,6 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import '@mantine/dates/styles.css';
import { Notifications } from "@mantine/notifications";
import { ModalsProvider } from "@mantine/modals";
import { MantineProvider } from "@mantine/core";

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" data-mantine-color-scheme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -1,15 +1,163 @@
import clientRoutes from "@/clientRoutes";
import { Button, Container } from "@mantine/core";
import {
Button,
Container,
Text,
Title,
Stack,
Group,
SimpleGrid,
ThemeIcon,
Paper,
Box,
rem,
Divider,
Badge,
} from "@mantine/core";
import {
IconBrandWhatsapp,
IconRocket,
IconShieldCheck,
IconPlugConnected,
IconArrowRight,
} from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
const features = [
{
icon: IconBrandWhatsapp,
title: "WhatsApp Integration",
description: "Connect and automate WhatsApp messages effortlessly using wa-web.js technology.",
color: "green",
},
{
icon: IconPlugConnected,
title: "Webhooks & API",
description: "Integrate with your existing systems via powerful webhooks and a developer-friendly API.",
color: "blue",
},
{
icon: IconRocket,
title: "High Performance",
description: "Built on Bun and ElysiaJS for maximum speed and efficient resource management.",
color: "orange",
},
{
icon: IconShieldCheck,
title: "Secure & Reliable",
description: "Manage API keys and sessions securely with a robust management dashboard.",
color: "teal",
},
];
export default function Home() {
const navigate = useNavigate();
return (
<Container>
<h1>Home</h1>
<Button onClick={() => navigate(clientRoutes["/sq/dashboard"])}>
Go to SQ
</Button>
</Container>
<Box bg="var(--mantine-color-body)" style={{ minHeight: "100vh" }}>
{/* Hero Section */}
<Container size="lg" pt={{ base: 80, md: 120 }} pb={80}>
<Stack align="center" gap="xl">
<ThemeIcon size={80} radius="xl" color="green" variant="light">
<IconBrandWhatsapp size={50} />
</ThemeIcon>
<Box style={{ textAlign: "center" }}>
<Title
order={1}
style={{
fontSize: rem(60),
fontWeight: 900,
lineHeight: 1.1,
marginBottom: rem(20),
}}
>
Master Your{" "}
<Text
component="span"
variant="gradient"
gradient={{ from: "green", to: "teal" }}
inherit
>
WhatsApp
</Text>{" "}
Workflow
</Title>
<Container size="sm" p={0}>
<Text size="lg" c="dimmed" mb={40}>
A robust, full-stack WhatsApp integration platform built with Bun.
Send messages, manage webhooks, and automate your communication
with ease.
</Text>
</Container>
<Group justify="center" mt="xl">
<Button
size="lg"
color="green"
radius="md"
px={40}
rightSection={<IconArrowRight size={20} />}
onClick={() => navigate(clientRoutes["/sq/dashboard"])}
>
Get Started
</Button>
<Button
size="lg"
variant="outline"
color="gray"
radius="md"
px={40}
onClick={() => navigate(clientRoutes["/login"])}
>
Login
</Button>
</Group>
</Box>
</Stack>
</Container>
{/* Features Section */}
<Box bg="var(--mantine-color-dark-8)" py={80}>
<Container size="lg">
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing={40}>
{features.map((feature) => (
<Paper key={feature.title} bg="transparent">
<ThemeIcon
size={44}
radius="md"
variant="light"
color={feature.color}
mb="md"
>
<feature.icon size={26} stroke={2} />
</ThemeIcon>
<Text fw={700} size="lg" mb="xs">
{feature.title}
</Text>
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
{feature.description}
</Text>
</Paper>
))}
</SimpleGrid>
</Container>
</Box>
{/* Footer */}
<Container size="lg" py="xl">
<Divider mb="xl" variant="dotted" />
<Group justify="space-between">
<Text size="sm" c="dimmed">
© 2026 wajs-server. Built with Bun & Mantine.
</Text>
<Group gap="xs">
<Badge variant="dot" color="green">Production Ready</Badge>
<Badge variant="dot" color="blue">v1.0.0</Badge>
</Group>
</Group>
</Container>
</Box>
);
}
}

View File

@@ -1,28 +1,43 @@
import {
Button,
Container,
Group,
PasswordInput,
Stack,
Paper,
Text,
TextInput,
PasswordInput,
Group,
Stack,
Title,
Center,
Box,
ThemeIcon,
} from "@mantine/core";
import { useEffect, useState } from "react";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import { IconAt, IconLock, IconLogin, IconBrandWhatsapp } from "@tabler/icons-react";
import apiFetch from "../lib/apiFetch";
import clientRoutes from "@/clientRoutes";
import { Navigate } from "react-router-dom";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const form = useForm({
initialValues: {
email: "",
password: "",
},
validate: {
email: (value) => (/^\S+@\S+$/.test(value) ? null : "Invalid email"),
password: (value) => (value.length < 1 ? "Password is required" : null),
},
});
useEffect(() => {
async function checkSession() {
try {
// backend otomatis baca cookie JWT dari request
const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200);
} catch {
@@ -32,54 +47,103 @@ export default function Login() {
checkSession();
}, []);
const handleSubmit = async () => {
const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
try {
const response = await apiFetch.auth.login.post({
email,
password,
email: values.email,
password: values.password,
});
if (response.data?.token) {
localStorage.setItem("token", response.data.token);
notifications.show({
title: "Login Successful",
message: "Welcome back!",
color: "green",
});
window.location.href = clientRoutes["/sq/dashboard"];
return;
}
if (response.error) {
alert(JSON.stringify(response.error));
notifications.show({
title: "Login Failed",
message: (response.error as any)?.value?.message || "Invalid credentials",
color: "red",
});
}
} catch (error) {
console.error(error);
notifications.show({
title: "Error",
message: "An unexpected error occurred",
color: "red",
});
} finally {
setLoading(false);
}
};
if (isAuthenticated === null) return null; // or loading spinner
if (isAuthenticated === null) return null;
if (isAuthenticated)
return <Navigate to={clientRoutes["/sq/dashboard"]} replace />;
return (
<Container>
<Stack>
<Text>Login</Text>
<TextInput
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<PasswordInput
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Group justify="right">
<Button onClick={handleSubmit} disabled={loading}>
Login
</Button>
</Group>
</Stack>
</Container>
<Box
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
justifyContent: "center",
background: "var(--mantine-color-body)",
}}
>
<Container size={420} my={40}>
<Stack gap="xs" mb={30} align="center">
<ThemeIcon size={60} radius="xl" color="green" variant="light">
<IconBrandWhatsapp size={40} />
</ThemeIcon>
<Title ta="center" order={2} fw={900}>
Welcome Back!
</Title>
<Text c="dimmed" size="sm" ta="center">
Login to manage your WhatsApp integration
</Text>
</Stack>
<Paper withBorder shadow="md" p={30} radius="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Email address"
placeholder="hello@gmail.com"
required
leftSection={<IconAt size={16} />}
{...form.getInputProps("email")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
required
leftSection={<IconLock size={16} />}
{...form.getInputProps("password")}
/>
<Group justify="space-between" mt="lg">
<Button
fullWidth
type="submit"
loading={loading}
leftSection={<IconLogin size={18} />}
radius="md"
>
Sign in
</Button>
</Group>
</Stack>
</form>
</Paper>
</Container>
</Box>
);
}

View File

@@ -1,306 +1,342 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Center,
Container,
Divider,
Group,
Loader,
Paper,
ScrollArea,
Stack,
Table,
Text,
TextInput,
ScrollArea,
Divider,
Tooltip,
Badge,
Loader,
ActionIcon,
Center,
Title,
Tooltip
} from "@mantine/core";
import { IconKey, IconPlus, IconTrash, IconCopy } from "@tabler/icons-react";
import { DateInput } from "@mantine/dates";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import {
IconCalendar,
IconCopy,
IconInfoCircle,
IconKey,
IconPlus,
IconSearch,
IconTrash,
} from "@tabler/icons-react";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { showNotification } from "@mantine/notifications";
import apiFetch from "@/lib/apiFetch";
export default function ApiKeyPage() {
const [refresh, setRefresh] = useState(false);
const toggleRefresh = () => setRefresh((r) => !r);
return (
<Container
w={"100%"}
size="lg"
px="md"
py="xl"
style={{
background:
"radial-gradient(800px 400px at 10% 10%, rgba(0,255,200,0.05), transparent), radial-gradient(800px 400px at 90% 90%, rgba(0,255,255,0.04), transparent), linear-gradient(180deg, #0f0f0f 0%, #191919 100%)",
borderRadius: "20px",
boxShadow: "0 0 60px rgba(0,255,200,0.04)",
color: "#EAEAEA",
minHeight: "90vh",
}}
>
<Container w="100%" size="xl" py="xl">
<Stack gap="xl">
<Group justify="space-between">
<Group gap="xs">
<IconKey size={28} color="#00FFC8" />
<Text fw={700} fz={26} c="#EAEAEA">
API Key Management
</Text>
<Box>
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Group gap="xs">
<IconKey size={32} color="var(--mantine-color-teal-filled)" />
<Title order={1} fw={900}>
API Key Management
</Title>
</Group>
<Text c="dimmed" fz="sm">
Generate and manage secure access keys for your integrations.
</Text>
</Stack>
<Badge
size="lg"
radius="sm"
variant="light"
color="teal"
leftSection={<IconInfoCircle size={14} />}
>
Secure Access
</Badge>
</Group>
<Badge
size="lg"
radius="lg"
style={{
background:
"linear-gradient(90deg, rgba(0,255,200,0.08), rgba(0,255,255,0.05))",
border: "1px solid rgba(0,255,220,0.2)",
color: "#00FFC8",
}}
>
Secure Access
</Badge>
</Group>
<Divider color="rgba(0,255,200,0.1)" />
<CreateApiKey />
<Divider mt="md" variant="dotted" />
</Box>
<CreateApiKey onCreated={toggleRefresh} />
<ListApiKey refresh={refresh} />
</Stack>
</Container>
);
}
function CreateApiKey() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [expiredAt, setExpiredAt] = useState("");
function CreateApiKey({ onCreated }: { onCreated: () => void }) {
const [loading, setLoading] = useState(false);
const [refresh, setRefresh] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
showNotification({
title: "Missing name",
message: "Please enter a name for your API key",
const form = useForm({
initialValues: {
name: "",
description: "",
expiredAt: null as Date | null,
},
validate: {
name: (value) => (value.trim().length < 3 ? "Name must be at least 3 characters" : null),
},
});
const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
try {
const res = await apiFetch.api.apikey.create.post({
name: values.name,
description: values.description,
expiredAt: values.expiredAt ? values.expiredAt.toISOString() : undefined,
});
if (res.status === 200) {
form.reset();
notifications.show({
title: "Success",
message: "API key created successfully",
color: "teal",
});
onCreated();
} else {
notifications.show({
title: "Error",
message: (res.error as any)?.message || "Failed to create API key",
color: "red",
});
}
} catch (err) {
console.error(err);
notifications.show({
title: "Error",
message: "An unexpected error occurred",
color: "red",
});
return;
}
setLoading(true);
const res = await apiFetch.api.apikey.create.post({
name,
description,
expiredAt,
});
setLoading(false);
if (res.status === 200) {
setName("");
setDescription("");
setExpiredAt("");
showNotification({
title: "Success",
message: "API key created successfully",
color: "teal",
});
setRefresh((r) => !r);
} finally {
setLoading(false);
}
};
return (
<Stack gap="xl">
<Card
p="xl"
radius="lg"
style={{
background:
"linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01))",
border: "1px solid rgba(0,255,200,0.1)",
boxShadow: "0 0 30px rgba(0,255,200,0.05)",
backdropFilter: "blur(6px)",
}}
>
<Stack gap="md">
<Group justify="space-between">
<Text fw={600} fz="lg" c="#EAEAEA">
Create New API Key
</Text>
<IconPlus size={22} color="#00FFC8" />
</Group>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput
label="Key Name"
placeholder="Enter key name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<TextInput
label="Description"
placeholder="Describe the key purpose"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<TextInput
label="Expiration Date"
placeholder="YYYY-MM-DD"
type="date"
value={expiredAt}
onChange={(e) => setExpiredAt(e.target.value)}
/>
<Group justify="right" mt="md">
<Button
variant="outline"
color="gray"
onClick={() => {
setName("");
setDescription("");
setExpiredAt("");
}}
>
Clear
</Button>
<Button
type="submit"
loading={loading}
style={{
background:
"linear-gradient(90deg, #00FFC8 0%, #00FFFF 100%)",
color: "#191919",
fontWeight: 600,
}}
>
Save Key
</Button>
</Group>
</Stack>
</form>
</Stack>
</Card>
<Paper withBorder shadow="sm" p="xl" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Text fw={700} fz="lg">
Create New API Key
</Text>
<IconPlus size={20} color="var(--mantine-color-dimmed)" />
</Group>
<ListApiKey refresh={refresh} />
</Stack>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Group align="flex-start" grow>
<TextInput
label="Key Name"
placeholder="e.g. Production Webhook"
required
{...form.getInputProps("name")}
/>
<TextInput
label="Description"
placeholder="What is this key for?"
{...form.getInputProps("description")}
/>
<DateInput
label="Expiration Date"
placeholder="Optional expiration"
leftSection={<IconCalendar size={16} />}
clearable
{...form.getInputProps("expiredAt")}
/>
</Group>
<Group justify="right" mt="xl">
<Button
variant="subtle"
color="gray"
onClick={() => form.reset()}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
loading={loading}
leftSection={<IconPlus size={18} />}
color="teal"
>
Generate Key
</Button>
</Group>
</form>
</Stack>
</Paper>
);
}
function ListApiKey({ refresh }: { refresh: boolean }) {
const [apiKeys, setApiKeys] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => {
const fetchApiKeys = async () => {
setLoading(true);
const fetchApiKeys = async () => {
setLoading(true);
try {
const res = await apiFetch.api.apikey.list.get();
if (res.status === 200) {
setApiKeys(res.data?.apiKeys || []);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
};
}
};
useEffect(() => {
fetchApiKeys();
}, [refresh]);
const filteredKeys = apiKeys.filter((key) =>
key.name.toLowerCase().includes(search.toLowerCase())
);
const handleDelete = async (id: string) => {
try {
const res = await apiFetch.api.apikey.delete.delete({ id });
if (res.status === 200) {
setApiKeys((prev) => prev.filter((a) => a.id !== id));
notifications.show({
title: "Deleted",
message: "API key removed successfully",
color: "red",
});
}
} catch (err) {
console.error(err);
notifications.show({
title: "Error",
message: "Failed to delete API key",
color: "red",
});
}
};
const handleCopy = (key: string) => {
navigator.clipboard.writeText(key);
notifications.show({
title: "Copied",
message: "API key copied to clipboard",
color: "teal",
icon: <IconCopy size={16} />,
});
};
return (
<Card
p="xl"
radius="lg"
style={{
background:
"linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01))",
border: "1px solid rgba(0,255,200,0.1)",
boxShadow: "0 0 30px rgba(0,255,200,0.05)",
backdropFilter: "blur(6px)",
}}
>
<Paper withBorder shadow="sm" p="xl" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Text fw={600} fz="lg" c="#EAEAEA">
<Text fw={700} fz="lg">
Active API Keys
</Text>
<TextInput
placeholder="Search keys..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.target.value)}
size="xs"
w={250}
/>
</Group>
<Divider color="rgba(0,255,200,0.05)" />
<Divider variant="dotted" />
{loading ? (
<Center py="xl">
<Loader color="teal" />
<Center py={50}>
<Stack align="center" gap="sm">
<Loader color="teal" size="lg" type="dots" />
<Text c="dimmed" fz="sm">
Fetching your keys...
</Text>
</Stack>
</Center>
) : apiKeys.length === 0 ? (
<Center py="xl">
<Text c="#9A9A9A">No API keys found</Text>
) : filteredKeys.length === 0 ? (
<Center py={50}>
<Stack align="center" gap="xs">
<IconKey size={48} color="var(--mantine-color-gray-4)" />
<Text fw={500} c="dimmed">
{search ? "No keys match your search" : "No API keys created yet"}
</Text>
</Stack>
</Center>
) : (
<ScrollArea>
<Table
highlightOnHover
verticalSpacing="sm"
horizontalSpacing="md"
style={{
color: "#EAEAEA",
borderCollapse: "separate",
borderSpacing: "0 8px",
}}
>
<Table highlightOnHover verticalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Expired</Table.Th>
<Table.Th>Expiration</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Updated</Table.Th>
<Table.Th align="right">Actions</Table.Th>
<Table.Th align="right" style={{ textAlign: "right" }}>
Actions
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys.map((apiKey: any, index: number) => (
<Table.Tr
key={index}
style={{
background: "rgba(255,255,255,0.02)",
borderRadius: 10,
transition: "background 0.15s ease",
}}
>
<Table.Td>{apiKey.name}</Table.Td>
<Table.Td c="#9A9A9A">{apiKey.description || "—"}</Table.Td>
{filteredKeys.map((apiKey: any) => (
<Table.Tr key={apiKey.id}>
<Table.Td>
{apiKey.expiredAt
? new Date(apiKey.expiredAt).toISOString().split("T")[0]
: "—"}
<Text fw={600} fz="sm">
{apiKey.name}
</Text>
</Table.Td>
<Table.Td>
{new Date(apiKey.createdAt).toISOString().split("T")[0]}
<Text fz="sm" c="dimmed" lineClamp={1}>
{apiKey.description || "No description"}
</Text>
</Table.Td>
<Table.Td>
{new Date(apiKey.updatedAt).toISOString().split("T")[0]}
{apiKey.expiredAt ? (
<Badge
color={dayjs().isAfter(dayjs(apiKey.expiredAt)) ? "red" : "gray"}
variant="dot"
>
{dayjs(apiKey.expiredAt).format("MMM DD, YYYY")}
</Badge>
) : (
<Text fz="xs" c="dimmed">
Never
</Text>
)}
</Table.Td>
<Table.Td align="right">
<Group gap={4} justify="right">
<Tooltip label="Copy Key" withArrow>
<Table.Td>
<Text fz="sm">{dayjs(apiKey.createdAt).format("MMM DD, YYYY")}</Text>
</Table.Td>
<Table.Td>
<Group gap={8} justify="right">
<Tooltip label="Copy API Key" withArrow position="top">
<ActionIcon
variant="light"
color="teal"
onClick={() => {
navigator.clipboard.writeText(apiKey.key);
showNotification({
title: "Copied",
message: "API key copied to clipboard",
color: "teal",
});
}}
onClick={() => handleCopy(apiKey.key)}
size="lg"
>
<IconCopy size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Key" withArrow>
<Tooltip label="Delete API Key" withArrow position="top">
<ActionIcon
variant="light"
color="red"
onClick={async () => {
await apiFetch.api.apikey.delete.delete({
id: apiKey.id,
});
setApiKeys((prev) =>
prev.filter((a) => a.id !== apiKey.id),
);
showNotification({
title: "Deleted",
message: "API key removed successfully",
color: "red",
});
}}
onClick={() => handleDelete(apiKey.id)}
size="lg"
>
<IconTrash size={18} />
</ActionIcon>
@@ -314,6 +350,6 @@ function ListApiKey({ refresh }: { refresh: boolean }) {
</ScrollArea>
)}
</Stack>
</Card>
</Paper>
);
}

View File

@@ -1,177 +1,113 @@
import { useEffect, useState } from "react";
import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
AppShell,
Avatar,
Button,
Card,
Box,
Burger,
Divider,
Flex,
Group,
Menu,
NavLink,
Paper,
ScrollArea,
Stack,
Text,
ThemeIcon,
Title,
Tooltip,
Badge,
UnstyledButton,
rem
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useDisclosure } from "@mantine/hooks";
import {
IconChevronLeft,
IconBrandWhatsapp,
IconChevronDown,
IconChevronRight,
IconDashboard,
IconHome,
IconKey,
IconWebhook,
IconBrandWhatsapp,
IconUser,
IconLogout,
IconSettings,
IconWebhook,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import apiFetch from "@/lib/apiFetch";
import clientRoutes from "@/clientRoutes";
function Logout() {
return (
<Group justify="center" mt="md">
<Button
variant="light"
color="red"
radius="xl"
size="compact-sm"
leftSection={<IconLogout size={16} />}
onClick={async () => {
await apiFetch.auth.logout.delete();
localStorage.removeItem("token");
window.location.href = "/login";
}}
>
Logout
</Button>
</Group>
);
}
export default function DashboardLayout() {
const [opened, setOpened] = useLocalStorage({
key: "nav_open",
defaultValue: true,
});
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const location = useLocation();
return (
<AppShell
padding="lg"
header={{ height: 60 }}
navbar={{
width: 270,
width: 280,
breakpoint: "sm",
collapsed: { mobile: !opened, desktop: !opened },
}}
styles={{
main: {
background: "#191919",
color: "#EAEAEA",
},
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
padding="md"
>
<AppShell.Navbar
p="md"
style={{
background: "rgba(30,30,30,0.8)",
backdropFilter: "blur(10px)",
borderRight: "1px solid rgba(0,255,200,0.15)",
// boxShadow: "0 0 18px rgba(0,255,200,0.1)",
}}
>
<AppShell.Section>
<Group justify="flex-end" p="xs">
<Tooltip
label={opened ? "Collapse navigation" : "Expand navigation"}
withArrow
color="cyan"
>
<ActionIcon
variant="light"
radius="xl"
onClick={() => setOpened((v) => !v)}
aria-label="Toggle navigation"
style={{
color: "#00FFC8",
background: "rgba(0,255,200,0.1)",
// boxShadow: "0 0 10px rgba(0,255,200,0.2)",
}}
>
{opened ? <IconChevronLeft /> : <IconChevronRight />}
</ActionIcon>
</Tooltip>
<AppShell.Header p="md">
<Group h="100%" justify="space-between">
<Group>
<Burger
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Burger
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
<Group gap="xs">
<ThemeIcon variant="light" color="teal" size="sm">
<IconBrandWhatsapp size={18} />
</ThemeIcon>
<Title order={4} fw={800} visibleFrom="xs">
WAJS SERVER
</Title>
</Group>
</Group>
</AppShell.Section>
<AppShell.Section grow component={ScrollArea}>
<Group>
<HostHeaderView />
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p="xs">
<AppShell.Section grow component={ScrollArea} mx="-xs" px="xs">
<NavigationDashboard />
</AppShell.Section>
<AppShell.Section>
<HostView />
<Divider my="sm" />
<NavigationFooter />
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>
<Stack gap="md">
<Paper
withBorder
shadow="lg"
radius="xl"
p="md"
style={{
background: "rgba(45,45,45,0.6)",
backdropFilter: "blur(8px)",
border: "1px solid rgba(0,255,200,0.2)",
}}
>
<Flex align="center" gap="md">
{!opened && (
<Tooltip label="Open navigation menu" withArrow color="cyan">
<ActionIcon
variant="light"
radius="xl"
onClick={() => setOpened(true)}
aria-label="Open navigation"
style={{
color: "#00FFFF",
background: "rgba(0,255,200,0.1)",
}}
>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Title order={3} fw={600} c="#EAEAEA">
Control Center
</Title>
<Badge
variant="light"
color="teal"
size="sm"
style={{
background: "rgba(0,255,200,0.15)",
color: "#00FFFF",
}}
>
Live
</Badge>
</Flex>
</Paper>
<AppShell.Main bg="var(--mantine-color-dark-9)">
<Box
style={{
maxWidth: 1200,
margin: "0 auto",
width: "100%",
}}
>
<Outlet />
</Stack>
</Box>
</AppShell.Main>
</AppShell>
);
}
function HostView() {
function HostHeaderView() {
const [host, setHost] = useState<User | null>(null);
const navigate = useNavigate();
useEffect(() => {
async function fetchHost() {
@@ -181,51 +117,49 @@ function HostView() {
fetchHost();
}, []);
const handleLogout = async () => {
await apiFetch.auth.logout.delete();
localStorage.removeItem("token");
window.location.href = "/login";
};
if (!host) return null;
return (
<Card
radius="xl"
withBorder
shadow="md"
p="md"
style={{
background: "rgba(45,45,45,0.6)",
border: "1px solid rgba(0,255,200,0.15)",
// boxShadow: "0 0 12px rgba(0,255,200,0.1)",
}}
>
{host ? (
<Stack gap="sm">
<Flex gap="md" align="center">
<Avatar
size="lg"
radius="xl"
style={{
background:
"linear-gradient(145deg, rgba(0,255,200,0.3), rgba(0,255,255,0.4))",
color: "#EAEAEA",
fontWeight: 700,
}}
>
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap="xs">
<Avatar color="teal" radius="xl" size="sm">
{host.name?.[0]}
</Avatar>
<Stack gap={2}>
<Text fw={600} c="#EAEAEA">
<Box visibleFrom="sm" style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{host.name}
</Text>
<Text size="sm" c="#9A9A9A">
{host.email}
</Text>
</Stack>
</Flex>
<Divider color="rgba(0,255,200,0.2)" />
<Logout />
</Stack>
) : (
<Text size="sm" c="#9A9A9A" ta="center">
Host data unavailable
</Text>
)}
</Card>
</Box>
<IconChevronDown size={14} color="var(--mantine-color-dimmed)" />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Application</Menu.Label>
<Menu.Item
leftSection={<IconSettings style={{ width: rem(14), height: rem(14) }} />}
>
Settings
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
onClick={handleLogout}
leftSection={<IconLogout style={{ width: rem(14), height: rem(14) }} />}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
@@ -237,61 +171,63 @@ function NavigationDashboard() {
{
path: "/sq/dashboard/dashboard",
label: "Overview",
icon: <IconDashboard size={20} color="#00FFFF" />,
desc: "Main dashboard insights",
icon: <IconDashboard size={20} />,
},
{
path: "/sq/dashboard/wajs/wajs-home",
label: "WhatsApp Service",
icon: <IconBrandWhatsapp size={20} />,
},
{
path: "/sq/dashboard/wa-hook/wa-hook-home",
label: "Hook Activity",
icon: <IconWebhook size={20} />,
},
{
path: "/sq/dashboard/webhook/webhook-home",
label: "Webhooks Config",
icon: <IconSettings size={20} />,
},
{
path: "/sq/dashboard/apikey/apikey",
label: "API Keys",
icon: <IconKey size={20} color="#00FFFF" />,
desc: "Manage and regenerate access tokens",
},
{
path: "/sq/dashboard/wajs/wajs-home",
label: "Wajs Integration",
icon: <IconBrandWhatsapp size={20} color="#00FFFF" />,
desc: "WhatsApp session manager",
},
{
path: "/sq/dashboard/webhook/webhook-home",
label: "Webhooks",
icon: <IconWebhook size={20} color="#00FFFF" />,
desc: "Incoming and outgoing event handlers",
},
{
path: clientRoutes["/sq/dashboard/wa-hook/wa-hook-home"],
label: "WA Hook",
icon: <IconWebhook size={20} color="#00FFFF" />,
desc: "WA Hook",
icon: <IconKey size={20} />,
},
];
return (
<Stack gap="xs">
<Stack gap={4}>
{items.map((item) => (
<NavLink
key={item.path}
active={location.pathname.startsWith(item.path)}
leftSection={item.icon}
label={item.label}
description={item.desc}
onClick={() =>
navigate(clientRoutes[item.path as keyof typeof clientRoutes])
}
style={{
borderRadius: "12px",
color: "#EAEAEA",
background: location.pathname.startsWith(item.path)
? "rgba(0,255,200,0.15)"
: "transparent",
transition: "background 0.2s ease",
}}
styles={{
label: { fontWeight: 500, color: "#EAEAEA" },
description: { color: "#9A9A9A" },
}}
variant="light"
color="teal"
style={{ borderRadius: rem(8) }}
rightSection={<IconChevronRight size={14} stroke={1.5} />}
/>
))}
</Stack>
);
}
function NavigationFooter() {
const navigate = useNavigate();
return (
<Stack gap={4}>
<NavLink
leftSection={<IconHome size={20} />}
label="Back to Home"
onClick={() => navigate("/")}
variant="subtle"
color="gray"
style={{ borderRadius: rem(8) }}
/>
</Stack>
);
}

View File

@@ -1,7 +1,219 @@
import {
Title,
Text,
Container,
Grid,
Paper,
Group,
Stack,
ThemeIcon,
Badge,
Button,
Box,
SimpleGrid,
List,
ThemeIcon as MantineThemeIcon,
rem,
} from "@mantine/core";
import {
IconDashboard,
IconBrandWhatsapp,
IconKey,
IconWebhook,
IconCircleCheck,
IconCircleX,
IconPlayerPlay,
IconArrowRight,
IconSettings,
} from "@tabler/icons-react";
import useSWR from "swr";
import apiFetch from "@/lib/apiFetch";
import clientRoutes from "@/clientRoutes";
import { useNavigate } from "react-router-dom";
export default function Dashboard() {
const navigate = useNavigate();
const { data: waState } = useSWR("/wa/state", apiFetch.api.wa.state.get, {
refreshInterval: 5000,
});
const isWaReady = waState?.data?.state?.ready;
return (
<div>
<h1>Dashboard</h1>
</div>
<Stack gap="xl" py="sm">
{/* Header Section */}
<Box>
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Title order={2} fw={900}>
System Overview
</Title>
<Text c="dimmed" size="sm">
Welcome to your WhatsApp Integration Control Center.
</Text>
</Stack>
<Badge
size="lg"
variant="dot"
color={isWaReady ? "green" : "red"}
p="md"
>
System {isWaReady ? "Online" : "Action Required"}
</Badge>
</Group>
</Box>
{/* Main Stats / Status Cards */}
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
<StatusCard
title="WhatsApp Service"
status={isWaReady ? "Connected" : "Disconnected"}
color={isWaReady ? "green" : "red"}
icon={IconBrandWhatsapp}
description={isWaReady ? "Active and receiving hooks" : "Start service to begin"}
actionLabel={isWaReady ? "Manage" : "Start Now"}
onAction={() => navigate(clientRoutes["/sq/dashboard/wajs/wajs-home"])}
/>
<StatusCard
title="API Keys"
status="Active"
color="blue"
icon={IconKey}
description="3 keys currently active"
actionLabel="View Keys"
onAction={() => navigate(clientRoutes["/sq/dashboard/apikey/apikey"])}
/>
<StatusCard
title="Webhooks"
status="Enabled"
color="violet"
icon={IconWebhook}
description="8 endpoints configured"
actionLabel="Configure"
onAction={() => navigate(clientRoutes["/sq/dashboard/webhook/webhook-home"])}
/>
</SimpleGrid>
<Grid gutter="xl">
{/* Getting Started / Guide */}
<Grid.Col span={{ base: 12, md: 7 }}>
<Paper withBorder p="xl" radius="md" h="100%">
<Title order={3} mb="lg">Getting Started</Title>
<Stack gap="md">
<List
spacing="md"
size="sm"
center
icon={
<ThemeIcon color="teal" size={24} radius="xl">
<IconCircleCheck size={16} />
</ThemeIcon>
}
>
<List.Item>
<Text fw={600}>Scan WhatsApp QR Code</Text>
<Text size="xs" c="dimmed">Go to WhatsApp Service and scan the QR to link your device.</Text>
</List.Item>
<List.Item>
<Text fw={600}>Generate API Key</Text>
<Text size="xs" c="dimmed">Create a secure key to authenticate your external requests.</Text>
</List.Item>
<List.Item>
<Text fw={600}>Configure Webhooks</Text>
<Text size="xs" c="dimmed">Set up URLs to receive real-time notifications for incoming messages.</Text>
</List.Item>
<List.Item>
<Text fw={600}>Start Automation</Text>
<Text size="xs" c="dimmed">Your system is now ready to send and receive messages automatically.</Text>
</List.Item>
</List>
<Button
variant="light"
color="teal"
mt="md"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate(clientRoutes["/sq/dashboard/wajs/wajs-home"])}
>
Go to WhatsApp Service
</Button>
</Stack>
</Paper>
</Grid.Col>
{/* Quick Tips / Info */}
<Grid.Col span={{ base: 12, md: 5 }}>
<Stack h="100%">
<Paper withBorder p="xl" radius="md" bg="var(--mantine-color-dark-8)">
<Group mb="xs">
<ThemeIcon variant="light" color="blue">
<IconSettings size={18} />
</ThemeIcon>
<Text fw={700}>Developer Pro Tip</Text>
</Group>
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
You can use the API Key in the "Authorization" header as a Bearer token
to send messages programmatically via our REST API.
</Text>
</Paper>
<Paper withBorder p="xl" radius="md" flex={1}>
<Title order={4} mb="xs">System Health</Title>
<Stack gap="xs">
<HealthItem label="Database" status="Healthy" color="green" />
<HealthItem label="WhatsApp Client" status={isWaReady ? "Online" : "Offline"} color={isWaReady ? "green" : "red"} />
<HealthItem label="Webhook Dispatcher" status="Active" color="green" />
</Stack>
</Paper>
</Stack>
</Grid.Col>
</Grid>
</Stack>
);
}
interface StatusCardProps {
title: string;
status: string;
color: string;
icon: React.ElementType;
description: string;
actionLabel: string;
onAction: () => void;
}
function StatusCard({ title, status, color, icon: Icon, description, actionLabel, onAction }: StatusCardProps) {
return (
<Paper withBorder p="lg" radius="md" shadow="sm">
<Stack gap="md">
<Group justify="space-between">
<ThemeIcon size={44} radius="md" variant="light" color={color}>
<Icon size={24} />
</ThemeIcon>
<Badge color={color} variant="light">{status}</Badge>
</Group>
<Box>
<Text fw={700} size="lg">{title}</Text>
<Text size="xs" c="dimmed" mt={4}>{description}</Text>
</Box>
<Button variant="subtle" color={color} fullWidth mt="xs" onClick={onAction}>
{actionLabel}
</Button>
</Stack>
</Paper>
);
}
function HealthItem({ label, status, color }: { label: string; status: string; color: string }) {
return (
<Group justify="space-between">
<Text size="sm">{label}</Text>
<Group gap={6}>
<Box w={8} h={8} bg={`${color}.6`} style={{ borderRadius: "50%" }} />
<Text size="xs" fw={700} c={color}>{status}</Text>
</Group>
</Group>
);
}

View File

@@ -1,32 +1,42 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Avatar,
Badge,
Box,
Button,
Card,
Container,
Center,
Divider,
Group,
Pagination,
Paper,
rem,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
Badge,
ScrollArea,
Tooltip,
Divider,
Tooltip
} from "@mantine/core";
import { useLocalStorage, useShallowEffect } from "@mantine/hooks";
import { showNotification } from "@mantine/notifications";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import {
IconRefresh,
IconMessageCircle,
IconUser,
IconCalendar,
IconActivity,
IconHash,
IconCode,
IconMessageCircle,
IconPhone,
IconRefresh,
IconRobot,
IconTrash,
IconUser
} from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import useSWR from "swr";
dayjs.extend(relativeTime);
export default function WaHookHome() {
const [page, setPage] = useLocalStorage({ key: "wa-hook-page", defaultValue: 1 });
const { data, error, isLoading, mutate } = useSWR(
@@ -43,195 +53,202 @@ export default function WaHookHome() {
mutate();
}, [page]);
async function handleReset() {
await apiFetch["wa-hook"].reset.post();
mutate();
showNotification({
title: "Reset Completed",
message: "All WhatsApp Hook data has been cleared.",
color: "teal",
const handleReset = () => {
modals.openConfirmModal({
title: "Clear Activity Logs",
centered: true,
children: (
<Text size="sm">
Are you sure you want to clear all WhatsApp activity logs? This action cannot be undone.
</Text>
),
labels: { confirm: "Clear All", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
await apiFetch["wa-hook"].reset.post();
mutate();
notifications.show({
title: "Logs Cleared",
message: "All activity data has been successfully removed.",
color: "teal",
});
},
});
}
};
if (isLoading)
return (
<Stack gap="md" py="sm">
<Skeleton height={100} radius="md" />
<Skeleton height={150} radius="md" />
</Stack>
);
if (isLoading) return <Skeleton height={600} radius="lg" />;
if (error)
return (
<Container p="xl">
<Text c="red.5" ta="center" fz="lg" fw={500}>
Failed to load webhook data.
</Text>
</Container>
<Center py={100}>
<Paper withBorder p="xl" radius="md">
<Text c="red" fw={600}>Failed to load activity data</Text>
<Button variant="light" color="red" mt="md" onClick={() => mutate()}>Retry</Button>
</Paper>
</Center>
);
return (
<Container
size="lg"
p="xl"
style={{
background: "linear-gradient(145deg, #1a1a1a 0%, #111 100%)",
borderRadius: 24,
border: "1px solid rgba(0,255,200,0.15)",
boxShadow: "0 0 30px rgba(0,255,200,0.1)",
}}
>
<Stack gap="xl">
<Group justify="space-between" align="center">
<Stack gap={2}>
<Title order={2} c="#EAEAEA" fw={700} style={{ letterSpacing: 0.5 }}>
WhatsApp Hook Monitor
</Title>
<Text c="#9A9A9A" fz="sm">
Real-time webhook activity and message tracking
<Stack gap="xl" py="sm">
<Box>
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Group gap="xs">
<ThemeIcon variant="light" color="teal" size="lg">
<IconActivity size={20} />
</ThemeIcon>
<Title order={2} fw={900}>
Hook Activity Monitor
</Title>
</Group>
<Text c="dimmed" size="sm">
Track real-time WhatsApp messages and AI flow responses.
</Text>
</Stack>
<Tooltip label="Reset all webhook data" withArrow color="teal">
<Group gap="sm">
<Tooltip label="Refresh" withArrow>
<ActionIcon variant="default" size="lg" onClick={() => mutate()}>
<IconRefresh size={20} />
</ActionIcon>
</Tooltip>
<Button
color="red"
variant="light"
leftSection={<IconTrash size={18} />}
onClick={handleReset}
leftSection={<IconRefresh size={18} />}
variant="gradient"
gradient={{ from: "#00FFC8", to: "#00FFFF", deg: 45 }}
radius="xl"
size="md"
>
Reset Data
Clear Logs
</Button>
</Tooltip>
</Group>
</Group>
<Divider mt="md" variant="dotted" />
</Box>
<Divider color="rgba(0,255,200,0.2)" />
<Stack gap="md">
{data?.data?.list?.length ? (
data.data.list.map((item) => {
let parsed: any = {};
try {
parsed = typeof item.data === 'string' ? JSON.parse(item.data) : item.data;
} catch (e) {
parsed = {};
}
{/* <pre>{JSON.stringify(data?.data?.list, null, 2)}</pre> */}
return (
<Paper key={item.id} withBorder p="lg" radius="md" shadow="xs">
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<Avatar color="teal" radius="xl">
<IconUser size={20} />
</Avatar>
<Box>
<Text fw={700} fz="sm">
{parsed.name || "Unknown Sender"}
</Text>
<Group gap={4}>
<IconPhone size={12} color="var(--mantine-color-dimmed)" />
<Text fz="xs" c="dimmed">
{parsed.number || "No number"}
</Text>
</Group>
</Box>
</Group>
<Box style={{ textAlign: "right" }}>
<Text fz="xs" c="dimmed" fw={500}>
{dayjs(item.createdAt).format("MMM DD, HH:mm:ss")}
</Text>
<Text fz="xs" c="dimmed">
{dayjs(item.createdAt).fromNow()}
</Text>
</Box>
</Group>
<Stack gap="md">
{data?.data?.list?.length ? (
data.data.list.map((item) => {
const parsed = JSON.parse((item.data as any) || "{}");
return (
<Card
key={item.id}
radius="lg"
p="lg"
style={{
background:
"linear-gradient(160deg, rgba(45,45,45,0.9) 0%, rgba(25,25,25,0.95) 100%)",
border: "1px solid rgba(0,255,200,0.25)",
}}
>
<Stack gap={8}>
{/* Nama & Nomor Pengirim */}
<Group gap="xs" align="center">
<IconUser size={16} color="#00FFC8" />
<Text c="#EAEAEA" fw={500}>
{parsed.name || "Unknown Sender"} ({parsed.number || "No Number"})
<Box
p="md"
bg="var(--mantine-color-dark-8)"
style={{ borderRadius: rem(8), borderLeft: "4px solid var(--mantine-color-teal-6)" }}
>
<Group gap="xs" mb={4}>
<IconMessageCircle size={14} color="var(--mantine-color-teal-6)" />
<Text fz="xs" fw={700} c="teal" tt="uppercase">
Inbound Message
</Text>
</Group>
<Text fz="sm">{parsed.question || "(Empty message)"}</Text>
</Box>
{/* Pertanyaan / Pesan */}
<Group gap="xs" align="center">
<IconMessageCircle size={16} color="#00FFFF" />
<Text c="#9A9A9A" fz="sm">
{parsed.question || "(No question)"}
</Text>
</Group>
{/* ID Record */}
<Group gap="xs" align="center">
<IconHash size={16} color="#00FFC8" />
<Text c="#9A9A9A" fz="xs">
{item.id}
</Text>
</Group>
{/* Timestamp */}
<Group gap="xs" align="center">
<IconCalendar size={16} color="#00FFFF" />
<Text c="#9A9A9A" fz="xs">
{dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss")}
</Text>
</Group>
{/* Flow ID */}
{parsed.flowId && (
<Group gap="xs" align="center">
<IconCode size={16} color="#B554FF" />
<Badge
color="grape"
radius="sm"
variant="light"
styles={{
root: { backgroundColor: "rgba(181,84,255,0.15)", color: "#EAEAEA" },
}}
>
Flow: {parsed.flowId}
</Badge>
{parsed.answer && (
<Box
p="md"
bg="var(--mantine-color-teal-9)"
style={{
borderRadius: rem(8),
borderLeft: "4px solid var(--mantine-color-blue-6)",
marginLeft: rem(20)
}}
>
<Group justify="space-between" mb={4}>
<Group gap="xs">
<IconRobot size={16} color="var(--mantine-color-blue-4)" />
<Text fz="xs" fw={700} c="blue.4" tt="uppercase">
AI Response
</Text>
</Group>
{parsed.flowId && (
<Badge size="xs" color="blue" variant="light">
Flow: {parsed.flowId}
</Badge>
)}
</Group>
)}
<Text fz="sm">{parsed.answer}</Text>
</Box>
)}
{/* Jawaban */}
{parsed.answer && (
<Card
p="sm"
radius="md"
style={{
backgroundColor: "rgba(45,45,45,0.7)",
border: "1px solid rgba(0,255,255,0.15)",
}}
>
<Stack gap={4}>
<Text c="#EAEAEA" fw={500} fz="sm">
Bot Answer
</Text>
<Text c="#EAEAEA" fz="sm">
{parsed.answer}
</Text>
</Stack>
</Card>
)}
</Stack>
</Card>
);
})
) : (
<Card
radius="lg"
style={{
backgroundColor: "#2D2D2D",
border: "1px solid rgba(0,255,255,0.1)",
textAlign: "center",
padding: 60,
}}
>
<Text c="#9A9A9A" fz="lg">
No webhook activity detected yet.
<Group justify="space-between">
<Group gap="xs">
<IconHash size={12} color="var(--mantine-color-dimmed)" />
<Text fz="xs" c="dimmed" ff="monospace">
ID: {item.id}
</Text>
</Group>
</Group>
</Stack>
</Paper>
);
})
) : (
<Center py={80}>
<Stack align="center" gap="xs">
<IconActivity size={48} color="var(--mantine-color-gray-4)" />
<Text fw={500} c="dimmed">
No hook activity detected yet.
</Text>
</Card>
)}
</Stack>
<Group justify="center" mt="xl">
<Pagination
value={page}
total={Math.ceil((data?.data?.count || 1) / 10)}
onChange={(value) => {
setPage(value);
mutate();
}}
radius="xl"
withEdges
color="teal"
size="md"
styles={{
control: {
backgroundColor: "#2D2D2D",
border: "1px solid rgba(0,255,200,0.15)",
color: "#EAEAEA",
},
}}
/>
</Group>
</Stack>
</Center>
)}
</Stack>
</Container>
<Group justify="center" mt="xl">
<Pagination
value={page}
total={Math.ceil((data?.data?.count || 1) / 10)}
onChange={(value) => {
setPage(value);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
color="teal"
size="sm"
radius="md"
withEdges
/>
</Group>
</Stack>
);
}
}

View File

@@ -7,7 +7,7 @@ export default function WaHookLayout() {
return (
<Container size="xl" w={"100%"}>
<Group justify="flex-start" p={"md"}>
<Button
{/* <Button
color="cyan"
size="xs"
radius={"lg"}
@@ -16,7 +16,7 @@ export default function WaHookLayout() {
}
>
Flow WA Hook
</Button>
</Button> */}
</Group>
<Outlet />
</Container>

View File

@@ -1,3 +1,326 @@
import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
import {
Badge,
Box,
Button,
Center,
Divider,
Grid,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
TextInput,
Textarea,
rem,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import {
IconArrowRight,
IconBolt,
IconChevronRight,
IconClock,
IconKey,
IconMessage2,
IconWebhook,
IconSend,
IconPhone,
} from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { useState } from "react";
import { Link } from "react-router-dom";
import useSWR from "swr";
dayjs.extend(relativeTime);
export default function WajsHome() {
return <h1>Wajs Home</h1>;
const [sending, setSending] = useState(false);
const { data: hookData, isLoading: hooksLoading } = useSWR(
"/wa-hook/list?limit=5",
() => apiFetch["wa-hook"].list.get({ query: { limit: 5 } })
);
const form = useForm({
initialValues: {
number: "",
text: "",
},
validate: {
number: (value) => (value.length < 5 ? "Invalid phone number" : null),
text: (value) => (value.length < 1 ? "Message cannot be empty" : null),
},
});
const handleSendMessage = async (values: typeof form.values) => {
setSending(true);
try {
const { data, error } = await apiFetch.api.wa["send-text"].post(values);
if (error) {
notifications.show({
title: "Failed to send",
message: (error as any)?.value?.message || "Something went wrong",
color: "red",
});
} else {
notifications.show({
title: "Message Sent",
message: `Successfully sent to ${values.number}`,
color: "teal",
icon: <IconSend size={16} />,
});
form.reset();
}
} catch (err) {
notifications.show({
title: "Error",
message: "Network error or server unreachable",
color: "red",
});
} finally {
setSending(false);
}
};
const stats = [
{
title: "Incoming Messages",
value: hookData?.data && 'count' in hookData.data ? (hookData.data.count as number) * 10 : "...",
icon: IconMessage2,
color: "blue",
},
{
title: "Active Webhooks",
value: "8",
icon: IconWebhook,
color: "teal",
},
{
title: "API Keys",
value: "3",
icon: IconKey,
color: "violet",
},
];
const recentHooks = (hookData?.data && 'list' in hookData.data ? hookData.data.list : []) as any[];
return (
<Stack gap="xl" py="sm">
<Box>
<Title order={2} fw={900}>
Dashboard Overview
</Title>
<Text c="dimmed" fz="sm">
Monitor your WhatsApp integration activity and system health.
</Text>
</Box>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{stats.map((stat) => (
<Paper key={stat.title} withBorder p="md" radius="md">
<Group justify="space-between">
<Stack gap={0}>
<Text c="dimmed" fw={700} size="xs" tt="uppercase">
{stat.title}
</Text>
<Text fw={900} size="xl">
{stat.value}
</Text>
</Stack>
<ThemeIcon
color={stat.color}
variant="light"
size={48}
radius="md"
>
<stat.icon size={28} />
</ThemeIcon>
</Group>
</Paper>
))}
</SimpleGrid>
<Grid gutter="md">
<Grid.Col span={{ base: 12, md: 8 }}>
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Group gap="xs">
<ThemeIcon color="orange" variant="light" radius="sm">
<IconClock size={18} />
</ThemeIcon>
<Text fw={700}>Recent Hook Activity</Text>
</Group>
<Button
component={Link}
to={clientRoutes["/sq/dashboard/wa-hook"]}
variant="subtle"
size="xs"
rightSection={<IconChevronRight size={14} />}
>
View All
</Button>
</Group>
<Divider mb="sm" variant="dotted" />
<Stack gap="sm">
{hooksLoading ? (
<Center py="xl">
<Loader size="sm" type="dots" />
</Center>
) : recentHooks.length === 0 ? (
<Center py="xl">
<Text c="dimmed" fz="sm">
No recent activity found.
</Text>
</Center>
) : (
recentHooks.map((hook: any) => (
<Box
key={hook.id}
p="xs"
style={(theme) => ({
borderRadius: theme.radius.sm,
transition: "background-color 100ms ease",
"&:hover": {
backgroundColor: "var(--mantine-color-default-hover)",
},
})}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Badge size="xs" variant="outline" color="blue">
{hook.data?.type || "MESSAGE"}
</Badge>
<Box>
<Text fz="sm" fw={600} lineClamp={1}>
{hook.data?.text || "Media message received"}
</Text>
<Text fz="xs" c="dimmed">
From: {hook.data?.number || "Unknown"}
</Text>
</Box>
</Group>
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{dayjs(hook.createdAt).fromNow()}
</Text>
</Group>
</Box>
))
)}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="md">
<ThemeIcon color="green" variant="light" radius="sm">
<IconSend size={18} />
</ThemeIcon>
<Text fw={700}>Send Test Message</Text>
</Group>
<form onSubmit={form.onSubmit(handleSendMessage)}>
<Stack gap="sm">
<TextInput
label="Phone Number"
placeholder="6281234567890 or 1234567890@lid"
required
leftSection={<IconPhone size={16} />}
{...form.getInputProps("number")}
/>
<Textarea
label="Message Text"
placeholder="Type your message here..."
required
minRows={3}
{...form.getInputProps("text")}
/>
<Button
type="submit"
loading={sending}
color="teal"
leftSection={<IconSend size={18} />}
fullWidth
>
Send Message
</Button>
</Stack>
</form>
</Paper>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Paper withBorder radius="md" p="md" h="100%">
<Stack justify="space-between" h="100%">
<Box>
<Group gap="xs" mb="xs">
<ThemeIcon color="teal" variant="filled" radius="sm">
<IconBolt size={18} />
</ThemeIcon>
<Text fw={700}>Quick Actions</Text>
</Group>
<Text fz="sm" c="dimmed" mb="lg">
Commonly used tools and management options.
</Text>
<Stack gap="xs">
<Button
component={Link}
to={clientRoutes["/sq/dashboard/webhook"]}
color="teal"
justify="space-between"
rightSection={<IconArrowRight size={16} />}
>
Manage Webhooks
</Button>
<Button
component={Link}
to={clientRoutes["/sq/dashboard/apikey/apikey"]}
color="teal"
justify="space-between"
rightSection={<IconArrowRight size={16} />}
>
API Keys
</Button>
<Button
component={Link}
to={clientRoutes["/sq/dashboard/wa-hook"]}
color="teal"
justify="space-between"
rightSection={<IconArrowRight size={16} />}
>
Activity Logs
</Button>
</Stack>
</Box>
<Box mt="xl">
<Paper p="xs" radius="sm" withBorder>
<Group gap="xs">
<Box
w={8}
h={8}
bg="green.6"
style={{ borderRadius: "50%" }}
/>
<Text fz="xs" fw={700}>
System Online
</Text>
</Group>
</Paper>
</Box>
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -1,10 +1,32 @@
import { Navigate, Outlet } from "react-router-dom";
import useSWR from "swr";
import apiFetch from "@/lib/apiFetch";
import { Badge, Button, Chip, Group, Pill, Stack, Text } from "@mantine/core";
import {
Badge,
Button,
Group,
Stack,
Text,
Paper,
Title,
Divider,
ActionIcon,
Tooltip,
Box,
Loader,
} from "@mantine/core";
import { useState } from "react";
import clientRoutes from "@/clientRoutes";
import { modals } from "@mantine/modals";
import {
IconPlayerPlay,
IconRefresh,
IconScan,
IconCircleCheck,
IconAlertCircle,
IconSettings,
IconDeviceMobile,
} from "@tabler/icons-react";
export default function WajsLayout() {
const [loading, setLoading] = useState(false);
@@ -13,59 +35,145 @@ export default function WajsLayout() {
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshInterval: 3000,
onSuccess(data, key, config) {
console.log(data.data?.state);
},
});
if (!data?.data?.state) return <Outlet />;
if (data.data?.state.qr)
const state = data?.data?.state;
if (!state) return <Outlet />;
if (state.qr) {
return <Navigate to={clientRoutes["/wajs/qrcode"]} replace />;
}
const handleStart = async () => {
setLoading(true);
await apiFetch.api.wa.start.post();
setLoading(false);
};
const handleRestart = async () => {
setLoading(true);
await apiFetch.api.wa.restart.post();
setLoading(false);
};
const handleRescan = () => {
modals.openConfirmModal({
title: "Rescan QR Code",
centered: true,
children: (
<Text size="sm">
Are you sure you want to rescan the QR code? This will disconnect the
current session and require a new login.
</Text>
),
labels: { confirm: "Rescan Now", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
setLoading(true);
await apiFetch.api.wa["force-start"].post();
setLoading(false);
},
});
};
return (
<Stack>
<Group>
<Button
loading={loading && !data.data?.state.ready}
disabled={data.data?.state.ready}
onClick={() => {
setLoading(true);
apiFetch.api.wa.start.post();
}}
>
{data.data?.state.ready ? "Ready" : "Start"}
</Button>
<Button
onClick={() => {
setLoading(true);
apiFetch.api.wa.restart.post();
}}
>
Reconnect
</Button>
<Button
color="red"
onClick={() => {
setLoading(true);
modals.openConfirmModal({
title: "Rescan QR",
children: <Text>Are you sure you want to rescan QR?</Text>,
confirmProps: { color: "red" },
labels: {
cancel: "Cancel",
confirm: "Rescan QR",
},
onCancel: () => setLoading(false),
onConfirm: () => {
apiFetch.api.wa.restart.post();
setLoading(false);
},
});
}}
>
Rescan QR
</Button>
</Group>
<Outlet />
<Stack gap="lg">
<Paper withBorder p="md" radius="md" shadow="sm">
<Group justify="space-between">
<Group gap="md">
<Box
p={8}
bg="teal.0"
style={{ borderRadius: "8px", display: "flex", alignItems: "center" }}
>
<IconDeviceMobile size={24} color="var(--mantine-color-teal-6)" />
</Box>
<Box>
<Title order={4}>WhatsApp Connection</Title>
<Group gap={6}>
{state.ready ? (
<Badge
color="green"
variant="light"
leftSection={<IconCircleCheck size={12} />}
>
Connected & Ready
</Badge>
) : state.isStarting ? (
<Badge
color="yellow"
variant="light"
leftSection={<Loader size={10} color="yellow" />}
>
Connecting...
</Badge>
) : (
<Badge
color="red"
variant="light"
leftSection={<IconAlertCircle size={12} />}
>
Disconnected
</Badge>
)}
</Group>
</Box>
</Group>
<Group gap="sm">
{!state.ready && (
<Button
size="sm"
leftSection={<IconPlayerPlay size={16} />}
loading={loading || state.isStarting}
onClick={handleStart}
color="teal"
>
Start Service
</Button>
)}
<Tooltip label="Reconnect the WhatsApp client" withArrow>
<Button
variant="light"
size="sm"
leftSection={<IconRefresh size={16} />}
onClick={handleRestart}
loading={loading}
disabled={!state.ready && !state.isStarting}
>
Reconnect
</Button>
</Tooltip>
<Tooltip label="Reset session and show QR" withArrow>
<Button
variant="subtle"
color="red"
size="sm"
leftSection={<IconScan size={16} />}
onClick={handleRescan}
loading={loading}
>
Rescan QR
</Button>
</Tooltip>
<Divider orientation="vertical" />
<Tooltip label="Settings" withArrow>
<ActionIcon variant="default" size="lg">
<IconSettings size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
<Box px="xs">
<Outlet />
</Box>
</Stack>
);
}

View File

@@ -1,310 +1,201 @@
import { useState, useMemo } from "react";
import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
import {
Button,
Card,
Checkbox,
Divider,
Group,
Select,
Stack,
Text,
TextInput,
Select,
Divider,
Title,
Paper,
Box,
SimpleGrid,
rem,
ThemeIcon,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import { IconCode, IconCheck, IconX } from "@tabler/icons-react";
import Editor from "@monaco-editor/react";
import apiFetch from "@/lib/apiFetch";
import {
IconCheck,
IconX,
IconArrowLeft,
IconLink,
IconKey,
IconWebhook,
} from "@tabler/icons-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import clientRoutes from "@/clientRoutes";
// data.from': data.from,
// data.fromNumber': data.fromNumber,
// data.fromMe': data.fromMe,
// data.body': data.body,
// data.hasMedia': data.hasMedia,
// data.type': data.type,
// data.to': data.to,
// data.deviceType': data.deviceType,
// data.notifyName': data.notifyName,
// data.media.data': data.media?.data ?? null,
// data.media.mimetype': data.media?.mimetype ?? null,
// data.media.filename': data.media?.filename ?? null,
// data.media.filesize': data.media?.filesize ?? 0,
const templateData = `
Available variables:
{{data.from}}, {{data.fromNumber}}, {{data.fromMe}}, {{data.body}}, {{data.hasMedia}}, {{data.type}}, {{data.to}}, {{data.deviceType}}, {{data.notifyName}}, {{data.media.data}}, {{data.media.mimetype}}, {{data.media.filename}}, {{data.media.filesize}}
`;
export default function WebhookCreate() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [url, setUrl] = useState("");
const [method, setMethod] = useState("POST");
const [headers, setHeaders] = useState(
JSON.stringify({ "Content-Type": "application/json" }, null, 2),
);
const [payload, setPayload] = useState("{}");
const [apiToken, setApiToken] = useState("");
const [enabled, setEnabled] = useState(true);
const [replay, setReplay] = useState(false);
const [replayKey, setReplayKey] = useState("");
const [loading, setLoading] = useState(false);
const safeJson = (value: string) => {
const form = useForm({
initialValues: {
name: "",
description: "",
url: "",
method: "POST",
apiToken: "",
headers: JSON.stringify({ "Content-Type": "application/json" }, null, 2),
payload: "{}",
enabled: true,
replay: false,
replayKey: "",
},
validate: {
name: (value) => (value.trim().length < 3 ? "Name must be at least 3 characters" : null),
url: (value) => (/^https?:\/\/.+/.test(value) ? null : "Invalid webhook URL"),
},
});
const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
try {
return JSON.stringify(JSON.parse(value || "{}"), null, 2);
} catch {
return value || "{}";
const { data } = await apiFetch.api.webhook.create.post(values);
if (data?.success) {
notifications.show({
title: "Success",
message: "Webhook created successfully",
color: "teal",
icon: <IconCheck size={18} />,
});
navigate(clientRoutes["/sq/dashboard/webhook"]);
} else {
throw new Error(data?.message || "Failed to create webhook");
}
} catch (err: any) {
notifications.show({
title: "Creation Failed",
message: err.message,
color: "red",
icon: <IconX size={18} />,
});
} finally {
setLoading(false);
}
};
const previewCode = useMemo(() => {
let headerObj: Record<string, string> = {};
try {
headerObj = JSON.parse(headers);
} catch {}
if (apiToken) headerObj["Authorization"] = `Bearer ${apiToken}`;
const prettyHeaders = safeJson(JSON.stringify(headerObj));
const prettyPayload = safeJson(payload);
const includeBody = ["POST", "PUT", "PATCH"].includes(method.toUpperCase());
return `fetch("${url || "https://example.com/webhook"}", {
method: "${method}",
headers: ${prettyHeaders},${includeBody ? `\n body: ${prettyPayload},` : ""}
})
.then(res => res.json())
.then(console.log)
.catch(console.error);`;
}, [url, method, headers, payload, apiToken]);
async function onSubmit() {
const { data } = await apiFetch.api.webhook.create.post({
name,
description,
apiToken,
url,
method,
headers,
payload,
enabled,
replay,
replayKey,
});
if (data?.success) {
notifications.show({
title: "Webhook Created",
message: data.message,
color: "teal",
icon: <IconCheck />,
});
navigate(clientRoutes["/sq/dashboard/webhook"]);
} else {
notifications.show({
title: "Creation Failed",
message: data?.message || "Unable to create webhook",
color: "red",
icon: <IconX />,
});
}
}
return (
<Stack style={{ backgroundColor: "#191919" }} p="xl">
<Stack
gap="md"
w={"100%"}
mx="auto"
bg="rgba(45,45,45,0.6)"
p="xl"
style={{
borderRadius: "20px",
backdropFilter: "blur(12px)",
border: "1px solid rgba(0,255,200,0.2)",
// boxShadow: "0 0 25px rgba(0,255,200,0.15)",
}}
>
<Group justify="space-between">
<Title order={2} c="#EAEAEA" fw={600}>
Create Webhook
</Title>
<IconCode color="#00FFFF" size={28} />
</Group>
<Divider color="rgba(0,255,200,0.2)" />
<TextInput
label="Name"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextInput
label="Description"
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<TextInput
label="Webhook URL"
placeholder="https://example.com/webhook"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Select
label="HTTP Method"
placeholder="Select method"
value={method}
onChange={(v) => setMethod(v || "POST")}
data={["POST", "GET", "PUT", "PATCH", "DELETE"].map((v) => ({
value: v,
label: v,
}))}
/>
<TextInput
label="API Token"
placeholder="Bearer ..."
value={apiToken}
onChange={(e) => {
setApiToken(e.target.value);
try {
const current = JSON.parse(headers);
if (!e.target.value) {
delete current["Authorization"];
} else {
current["Authorization"] = `Bearer ${e.target.value}`;
}
setHeaders(JSON.stringify(current, null, 2));
} catch {}
}}
/>
{/* <Stack gap="xs">
<Text fw={600} c="#EAEAEA">
Headers (JSON)
</Text>
<Editor
theme="vs-dark"
height="20vh"
language="json"
value={headers}
onChange={(val) => setHeaders(val ?? "{}")}
options={{
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
lineNumbers: "off",
automaticLayout: true,
}}
/>
</Stack> */}
{/* <Stack gap="xs">
<Text fw={600} c="#EAEAEA">
Payload
</Text>
<Text size="xs" c="#9A9A9A" mb="xs">
{templateData}
</Text>
<Editor
theme="vs-dark"
height="35vh"
language="json"
value={payload}
onChange={(val) => setPayload(val ?? "{}")}
options={{
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
</Stack> */}
<Checkbox
label="Enable Webhook"
checked={enabled}
onChange={(e) => setEnabled(e.currentTarget.checked)}
color="teal"
styles={{
label: { color: "#EAEAEA" },
}}
/>
{/* <Checkbox
label="Enable Replay"
checked={replay}
onChange={(e) => setReplay(e.currentTarget.checked)}
color="teal"
styles={{
label: { color: "#EAEAEA" },
}}
/> */}
{/* <TextInput
description="Replay Key is used to identify the webhook example: data.text"
label="Replay Key"
placeholder="Replay Key"
value={replayKey}
onChange={(e) => setReplayKey(e.target.value)}
/> */}
{/* <Card
radius="xl"
p="md"
style={{
background: "rgba(25,25,25,0.6)",
border: "1px solid rgba(0,255,200,0.3)",
// boxShadow: "0 0 15px rgba(0,255,200,0.15)",
}}
>
<Stack gap="xs">
<Text fw={600} c="#EAEAEA">
Request Preview
</Text>
<Editor
theme="vs-dark"
height="35vh"
language="javascript"
value={previewCode}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
<Stack gap="xl" py="sm">
<Box>
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Group gap="xs">
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconArrowLeft size={14} />}
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
p={0}
>
Back to List
</Button>
</Group>
<Group gap="xs">
<ThemeIcon variant="light" color="teal" size="lg">
<IconWebhook size={20} />
</ThemeIcon>
<Title order={2} fw={900}>
Create Webhook
</Title>
</Group>
</Stack>
</Card> */}
<Group justify="flex-end" mt="md">
<Button
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
variant="subtle"
c="#EAEAEA"
styles={{
root: { backgroundColor: "#2D2D2D", borderColor: "#00FFC8" },
}}
>
Cancel
</Button>
<Button
onClick={onSubmit}
style={{
background: "linear-gradient(90deg, #00FFC8, #00FFFF)",
color: "#191919",
}}
>
Save Webhook
</Button>
</Group>
</Stack>
<Divider mt="md" variant="dotted" />
</Box>
<Paper withBorder shadow="sm" p="xl" radius="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
<TextInput
label="Webhook Name"
placeholder="e.g. Production Webhook"
required
{...form.getInputProps("name")}
/>
<Select
label="HTTP Method"
placeholder="Select method"
required
data={["GET", "POST", "PUT", "PATCH", "DELETE"]}
{...form.getInputProps("method")}
/>
</SimpleGrid>
<TextInput
label="Endpoint URL"
placeholder="https://your-api.com/webhook"
required
leftSection={<IconLink size={16} />}
{...form.getInputProps("url")}
/>
<TextInput
label="Description"
placeholder="What is this webhook for?"
{...form.getInputProps("description")}
/>
<TextInput
label="API Token (Optional)"
placeholder="Bearer token or custom key"
leftSection={<IconKey size={16} />}
{...form.getInputProps("apiToken")}
/>
<Box
p="md"
bg="var(--mantine-color-dark-8)"
style={{
borderRadius: rem(8),
border: "1px solid var(--mantine-color-dark-4)",
}}
>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={700} size="sm">
Enable Webhook
</Text>
<Text size="xs" c="dimmed">
Activate this webhook to start receiving events immediately.
</Text>
</Stack>
<Checkbox
size="md"
color="teal"
{...form.getInputProps("enabled", { type: "checkbox" })}
/>
</Group>
</Box>
<Group justify="right" mt="xl">
<Button
variant="subtle"
color="gray"
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
loading={loading}
color="teal"
leftSection={<IconCheck size={18} />}
>
Create Webhook
</Button>
</Group>
</Stack>
</form>
</Paper>
</Stack>
);
}
}

View File

@@ -9,330 +9,273 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Paper,
ActionIcon,
Tooltip,
Container,
Box,
Loader,
Center,
SimpleGrid,
rem,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { IconCheck, IconCode, IconX } from "@tabler/icons-react";
import {
IconCheck,
IconCode,
IconX,
IconTrash,
IconArrowLeft,
IconLink,
IconKey,
IconInfoCircle,
} from "@tabler/icons-react";
import type { WebHook } from "generated/prisma";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import useSWR from "swr";
export default function WebhookEdit() {
const [searchParams] = useSearchParams();
const id = searchParams.get("id");
const navigate = useNavigate();
const { data, error, isLoading, mutate } = useSWR(
"/",
() =>
apiFetch.api.webhook
.find({
id: id!,
})
.get(),
{ dedupingInterval: 3000 },
id ? `/webhook/${id}` : null,
() => apiFetch.api.webhook.find({ id: id! }).get(),
{ dedupingInterval: 3000 }
);
const navigate = useNavigate();
useShallowEffect(() => {
mutate();
}, [data]);
const handleDelete = () => {
modals.openConfirmModal({
title: "Remove Webhook",
centered: true,
children: (
<Text size="sm">
Are you sure you want to remove this webhook? This action cannot be undone.
</Text>
),
labels: { confirm: "Delete Webhook", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
try {
await apiFetch.api.webhook.remove({ id: id! }).delete();
notifications.show({
title: "Deleted",
message: "Webhook has been removed",
color: "red",
});
navigate(clientRoutes["/sq/dashboard/webhook"]);
} catch (err) {
notifications.show({
title: "Error",
message: "Failed to delete webhook",
color: "red",
});
}
},
});
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!data?.data?.webhook) return <div>No data</div>;
if (isLoading)
return (
<Center py={100}>
<Loader color="teal" size="lg" type="dots" />
</Center>
);
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Edit Webhook</Title>
<Button
variant="outline"
onClick={() => {
modals.openConfirmModal({
title: "Remove Webhook",
children: (
<Text>Are you sure you want to remove this webhook?</Text>
),
confirmProps: { color: "red" },
labels: {
cancel: "Cancel",
confirm: "Remove",
},
onConfirm: () => {
apiFetch.api.webhook
.remove({
id: id!,
})
.delete();
navigate(clientRoutes["/sq/dashboard/webhook"]);
},
onCancel: () => {
navigate(
clientRoutes["/sq/dashboard/webhook/webhook-edit"] +
"?id=" +
id,
);
},
});
}}
>
Remove
</Button>
</Group>
<EditView webhook={data.data?.webhook || null} />
</Stack>
);
}
function EditView({ webhook }: { webhook: Partial<WebHook> | null }) {
const navigate = useNavigate();
const [name, setName] = useState(webhook?.name || "");
const [description, setDescription] = useState(webhook?.description || "");
const [url, setUrl] = useState(webhook?.url || "");
const [method, setMethod] = useState(webhook?.method || "POST");
const [headers, setHeaders] = useState(webhook?.headers || "{}");
const [apiToken, setApiToken] = useState(webhook?.apiToken || "");
const [enabled, setEnabled] = useState(webhook?.enabled );
async function onSubmit() {
if (!webhook?.id) {
return notifications.show({
title: "Webhook ID Not Found",
message: "Unable to update webhook",
color: "red",
icon: <IconX />,
});
}
const { data } = await apiFetch.api.webhook
.update({
id: webhook?.id,
})
.put({
name,
description,
apiToken,
url,
method,
headers,
enabled: enabled || false,
});
if (data?.success) {
notifications.show({
title: "Webhook Created",
message: data.message,
color: "teal",
icon: <IconCheck />,
});
navigate(clientRoutes["/sq/dashboard/webhook"]);
} else {
notifications.show({
title: "Creation Failed",
message: data?.message || "Unable to create webhook",
color: "red",
icon: <IconX />,
});
}
}
return (
<Stack style={{ backgroundColor: "#191919" }} p="xl" >
<Stack
gap="md"
w={"100%"}
mx="auto"
bg={enabled? "" : "rgba(47, 34, 34, 0.6)"}
p="xl"
style={{
borderRadius: "20px",
backdropFilter: "blur(12px)",
border: "1px solid rgba(0,255,200,0.2)",
// boxShadow: "0 0 25px rgba(0,255,200,0.15)",
}}
>
<Group justify="space-between">
<Title order={2} c="#EAEAEA" fw={600}>
Edit Webhook
</Title>
<IconCode color="#00FFFF" size={28} />
</Group>
<Divider color="rgba(0,255,200,0.2)" />
<TextInput
label="Name"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextInput
label="Description"
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<TextInput
label="Webhook URL"
placeholder="https://example.com/webhook"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Select
label="HTTP Method"
placeholder="Select method"
value={method}
onChange={(v) => setMethod(v || "POST")}
data={["POST", "GET", "PUT", "PATCH", "DELETE"].map((v) => ({
value: v,
label: v,
}))}
/>
<TextInput
label="API Token"
placeholder="Bearer ..."
value={apiToken}
onChange={(e) => {
setApiToken(e.target.value);
try {
const current = JSON.parse(headers);
if (!e.target.value) {
delete current["Authorization"];
} else {
current["Authorization"] = `Bearer ${e.target.value}`;
}
setHeaders(JSON.stringify(current, null, 2));
} catch {}
}}
/>
{/* <Stack gap="xs">
<Text fw={600} c="#EAEAEA">
Headers (JSON)
</Text>
<Editor
theme="vs-dark"
height="20vh"
language="json"
value={headers}
onChange={(val) => setHeaders(val ?? "{}")}
options={{
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
lineNumbers: "off",
automaticLayout: true,
}}
/>
</Stack> */}
{/* <Stack gap="xs">
<Text fw={600} c="#EAEAEA">
Payload
</Text>
<Text size="xs" c="#9A9A9A" mb="xs">
{templateData}
</Text>
<Editor
theme="vs-dark"
height="35vh"
language="json"
value={payload}
onChange={(val) => setPayload(val ?? "{}")}
options={{
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
</Stack> */}
<Checkbox
label="Enable Webhook"
defaultChecked={enabled}
onChange={(e) => setEnabled(e.target.checked as any)}
color="teal"
styles={{
label: { color: "#EAEAEA" },
}}
/>
{/* <Checkbox
label="Enable Replay"
checked={replay}
onChange={(e) => setReplay(e.target.checked as any)}
color="teal"
styles={{
label: { color: "#EAEAEA" },
}}
/> */}
{/* <TextInput
description="Replay Key is used to identify the webhook example: data.text"
label="Replay Key"
placeholder="Replay Key"
value={replayKey}
onChange={(e) => setReplayKey(e.target.value)}
/> */}
{/* <Card
radius="xl"
p="md"
style={{
background: "rgba(25,25,25,0.6)",
border: "1px solid rgba(0,255,200,0.3)",
// boxShadow: "0 0 15px rgba(0,255,200,0.15)",
}}
>
<Stack gap="xs">
<Text fw={600} c="#EAEAEA">
Request Preview
</Text>
<Editor
theme="vs-dark"
height="35vh"
language="javascript"
value={previewCode}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
if (error || !data?.data?.webhook)
return (
<Center py={100}>
<Paper withBorder p="xl" radius="md">
<Stack align="center">
<IconX size={48} color="red" />
<Text fw={600}>Webhook not found or error loading data</Text>
<Button variant="light" onClick={() => navigate(-1)}>
Go Back
</Button>
</Stack>
</Card> */}
</Paper>
</Center>
);
<Group justify="flex-end" mt="md">
return (
<Stack gap="xl" py="sm">
<Box>
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Group gap="xs">
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconArrowLeft size={14} />}
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
p={0}
>
Back to List
</Button>
</Group>
<Title order={2} fw={900}>
Edit Webhook
</Title>
</Stack>
<Button
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
variant="subtle"
c="#EAEAEA"
styles={{
root: { backgroundColor: "#2D2D2D", borderColor: "#00FFC8" },
}}
variant="light"
color="red"
leftSection={<IconTrash size={18} />}
onClick={handleDelete}
>
Cancel
</Button>
<Button
onClick={onSubmit}
style={{
background: "linear-gradient(90deg, #00FFC8, #00FFFF)",
color: "#191919",
}}
>
Save Webhook
Remove Webhook
</Button>
</Group>
</Stack>
<Divider mt="md" variant="dotted" />
</Box>
<EditView webhook={data.data.webhook} onUpdated={() => mutate()} />
</Stack>
);
}
function EditView({ webhook, onUpdated }: { webhook: Partial<WebHook>; onUpdated: () => void }) {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const form = useForm({
initialValues: {
name: webhook.name || "",
description: webhook.description || "",
url: webhook.url || "",
method: webhook.method || "POST",
apiToken: webhook.apiToken || "",
enabled: webhook.enabled ?? true,
},
validate: {
name: (value) => (value.trim().length < 3 ? "Name must be at least 3 characters" : null),
url: (value) => (/^https?:\/\/.+/.test(value) ? null : "Invalid webhook URL"),
},
});
const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
try {
const { data } = await apiFetch.api.webhook
.update({ id: webhook.id! })
.put({
...values,
headers: webhook.headers || "{}", // Maintain headers if any
});
if (data?.success) {
notifications.show({
title: "Success",
message: "Webhook updated successfully",
color: "teal",
icon: <IconCheck size={18} />,
});
onUpdated();
navigate(clientRoutes["/sq/dashboard/webhook"]);
} else {
throw new Error(data?.message || "Failed to update");
}
} catch (err: any) {
notifications.show({
title: "Update Failed",
message: err.message,
color: "red",
icon: <IconX size={18} />,
});
} finally {
setLoading(false);
}
};
return (
<Paper withBorder shadow="sm" p="xl" radius="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
<TextInput
label="Webhook Name"
placeholder="e.g. My Custom Integration"
required
{...form.getInputProps("name")}
/>
<Select
label="HTTP Method"
placeholder="Select method"
required
data={["GET", "POST", "PUT", "PATCH", "DELETE"]}
{...form.getInputProps("method")}
/>
</SimpleGrid>
<TextInput
label="Endpoint URL"
placeholder="https://your-api.com/webhook"
required
leftSection={<IconLink size={16} />}
{...form.getInputProps("url")}
/>
<TextInput
label="Description"
placeholder="What is this webhook for?"
{...form.getInputProps("description")}
/>
<TextInput
label="API Token (Optional)"
placeholder="Bearer token or custom key"
leftSection={<IconKey size={16} />}
{...form.getInputProps("apiToken")}
/>
<Box
p="md"
bg="var(--mantine-color-dark-8)"
style={{ borderRadius: rem(8), border: "1px solid var(--mantine-color-dark-4)" }}
>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={700} size="sm">
Enable Webhook
</Text>
<Text size="xs" c="dimmed">
When disabled, the system will stop sending events to this endpoint.
</Text>
</Stack>
<Checkbox
size="md"
color="teal"
{...form.getInputProps("enabled", { type: "checkbox" })}
/>
</Group>
</Box>
<Group justify="right" mt="xl">
<Button
variant="subtle"
color="gray"
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
loading={loading}
color="teal"
leftSection={<IconCheck size={18} />}
>
Update Webhook
</Button>
</Group>
</Stack>
</form>
</Paper>
);
}

View File

@@ -12,6 +12,11 @@ import {
Stack,
Divider,
Button,
Box,
SimpleGrid,
Paper,
rem,
ThemeIcon,
} from "@mantine/core";
import {
IconLink,
@@ -23,6 +28,9 @@ import {
IconEdit,
IconPlus,
IconMessageReply,
IconWebhook,
IconWorld,
IconExternalLink,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import useSWR from "swr";
@@ -34,7 +42,7 @@ import { useShallowEffect } from "@mantine/hooks";
export default function WebhookHome() {
const navigate = useNavigate();
const { data, error, isLoading, mutate } = useSWR(
"/",
"/webhook-list",
apiFetch.api.webhook.list.get,
{ dedupingInterval: 3000, refreshInterval: 3000 },
);
@@ -45,216 +53,209 @@ export default function WebhookHome() {
mutate();
}, []);
function ButtonCreate() {
return (
<Tooltip label="Create new webhook" withArrow color="teal">
<Button
radius="xl"
size="md"
leftSection={<IconPlus size={18} />}
variant="gradient"
gradient={{ from: "#00FFC8", to: "#00FFFF", deg: 135 }}
style={{
color: "#191919",
fontWeight: 600,
// boxShadow: "0 0 12px rgba(0,255,200,0.25)",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-2px)";
e.currentTarget.style.boxShadow = "0 0 20px rgba(0,255,200,0.4)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 0 12px rgba(0,255,200,0.25)";
}}
onClick={() => navigate("/sq/dashboard/webhook/webhook-create")}
>
Create Webhook
</Button>
</Tooltip>
);
}
const handleRefresh = () => {
mutate();
notifications.show({
title: "Refreshing Data",
message: "Webhook list has been updated.",
color: "teal",
});
};
if (isLoading)
return (
<Center h="100vh" bg="#191919">
<Loader color="teal" size="lg" />
<Center py={100}>
<Stack align="center" gap="md">
<Loader color="teal" size="lg" type="dots" />
<Text c="dimmed" fz="sm">Loading your webhooks...</Text>
</Stack>
</Center>
);
if (error)
return (
<Center h="100vh" bg="#191919">
<Text c="#FF4B4B" fw={500}>
Failed to load webhooks. Please try again.
</Text>
</Center>
);
if (!webhooks.length)
return (
<Center h="100vh" bg="#191919">
<Stack align="center" gap="sm">
<Text c="#9A9A9A" size="lg">
No webhooks found
</Text>
<Text c="#00FFC8" size="sm">
Connect your first webhook to start managing events
</Text>
<ButtonCreate />
</Stack>
<Center py={100}>
<Paper withBorder p="xl" radius="md" bg="var(--mantine-color-dark-8)">
<Stack align="center" gap="sm">
<IconX size={48} color="var(--mantine-color-red-6)" />
<Text fw={600}>Failed to load webhooks</Text>
<Button variant="light" color="red" onClick={() => mutate()}>
Try Again
</Button>
</Stack>
</Paper>
</Center>
);
return (
<Stack style={{ backgroundColor: "#191919" }} p="xl">
<Title order={2} c="#EAEAEA" fw={600}>
Webhook Manager
</Title>
<Group justify="end" mb="lg">
<ButtonCreate />
<Tooltip label="Refresh webhooks" withArrow color="cyan">
<ActionIcon
variant="light"
size="lg"
radius="xl"
onClick={() => {
mutate();
notifications.show({
title: "Refreshing data",
message: "Webhook list is being updated...",
color: "teal",
});
}}
>
<IconRefresh color="#00FFFF" />
</ActionIcon>
</Tooltip>
</Group>
<Stack gap="md">
{webhooks.map((webhook) => (
<Card
key={webhook.id}
p="lg"
radius="xl"
style={{
background: "rgba(45,45,45,0.6)",
backdropFilter: "blur(12px)",
border: "1px solid rgba(0,255,200,0.2)",
// boxShadow: "0 0 12px rgba(0,255,200,0.15)",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
}}
>
<Group justify="end" mb="sm">
<Group>
<IconLink color="#00FFFF" />
<Text c="#EAEAEA" fw={500} size="lg">
{webhook.name}
</Text>
</Group>
<ActionIcon
c={"teal"}
variant="light"
size="lg"
radius="xl"
onClick={() =>
navigate(
`${clientRoutes["/sq/dashboard/webhook/webhook-edit"]}?id=${webhook.id}`,
)
}
>
<IconEdit />
</ActionIcon>
<Stack gap="xl" py="sm">
<Box>
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Group gap="xs">
<IconWebhook size={32} color="var(--mantine-color-teal-filled)" />
<Title order={2} fw={900}>
Webhook Manager
</Title>
</Group>
<Text c="dimmed" size="sm">
Configure external endpoints to receive real-time event notifications.
</Text>
</Stack>
<Group gap="sm">
<Tooltip label="Refresh" withArrow>
<ActionIcon variant="default" size="lg" onClick={handleRefresh}>
<IconRefresh size={20} />
</ActionIcon>
</Tooltip>
<Button
leftSection={<IconPlus size={18} />}
color="teal"
onClick={() => navigate("/sq/dashboard/webhook/webhook-create")}
>
Add Webhook
</Button>
</Group>
</Group>
<Divider mt="md" variant="dotted" />
</Box>
<Stack gap={"md"}>
<Group>
<Badge
color={webhook.enabled ? "teal" : "red"}
radius="xl"
leftSection={
webhook.enabled ? (
<IconCheck size={14} />
) : (
<IconX size={14} />
)
}
>
{webhook.enabled ? "Active" : "Disabled"}
</Badge>
<Badge
bg={"teal"}
leftSection={<IconMessageReply size={16} color="#00FFC8" />}
>
{webhook.replay ? "Replay" : "Not Replay"}
</Badge>
</Group>
<Text c="#9A9A9A" size="sm">
{webhook.description}
{!webhooks.length ? (
<Center py={80}>
<Stack align="center" gap="xl">
<Box style={{ textAlign: "center" }}>
<ThemeIcon size={80} radius="xl" color="gray" variant="light" mb="md">
<IconWorld size={40} />
</ThemeIcon>
<Title order={3}>No Webhooks Configured</Title>
<Text c="dimmed" mt="xs">
Start by adding your first endpoint to receive WhatsApp events.
</Text>
</Stack>
<Divider color="rgba(0,255,200,0.2)" my="sm" />
</Box>
<Button
size="lg"
color="teal"
leftSection={<IconPlus size={20} />}
onClick={() => navigate("/sq/dashboard/webhook/webhook-create")}
>
Create Your First Webhook
</Button>
</Stack>
</Center>
) : (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{webhooks.map((webhook) => (
<Card
key={webhook.id}
p="xl"
radius="md"
withBorder
className="webhook-card"
style={{
transition: "transform 0.2s ease, box-shadow 0.2s ease",
cursor: "default",
}}
>
<Group justify="space-between" align="flex-start" mb="lg">
<Stack gap={4}>
<Text fw={700} fz="lg" lineClamp={1}>
{webhook.name || "Unnamed Webhook"}
</Text>
<Group gap="xs">
<Badge
color={webhook.enabled ? "teal" : "red"}
variant="dot"
size="sm"
>
{webhook.enabled ? "Active" : "Disabled"}
</Badge>
{webhook.replay && (
<Badge variant="light" color="blue" size="sm" leftSection={<IconMessageReply size={12} />}>
Replay
</Badge>
)}
</Group>
</Stack>
<Stack gap="xs">
<Group gap="xs">
<IconCode size={16} color="#00FFC8" />
<Text c="#9A9A9A" size="sm">
Method:
</Text>
<Text c="#EAEAEA" size="sm" fw={500}>
{webhook.method}
</Text>
<Tooltip label="Edit Settings" withArrow>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={() =>
navigate(
`${clientRoutes["/sq/dashboard/webhook/webhook-edit"]}?id=${webhook.id}`,
)
}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
</Group>
<Group gap="xs">
<IconLink size={16} color="#00FFC8" />
<Text c="#9A9A9A" size="sm">
URL:
</Text>
<Text c="#EAEAEA" size="sm" fw={500}>
{webhook.url}
</Text>
</Group>
<Text c="dimmed" fz="sm" lineClamp={2} mb="xl" h={rem(40)}>
{webhook.description || "No description provided for this webhook."}
</Text>
<Group gap="xs">
<IconKey size={16} color="#00FFC8" />
<Text c="#9A9A9A" size="sm">
API Token:
</Text>
<Text c="#EAEAEA" size="sm" fw={500}>
{webhook.apiToken?.slice(0, 6) + "..." || "—"}
</Text>
</Group>
<Divider mb="xl" variant="dashed" />
{/* <Group gap="xs">
<Text c="#9A9A9A" size="sm">
Headers:
</Text>
<Text c="#EAEAEA" size="sm" fw={500}>
{Object.keys(webhook.headers || {}).length
? webhook.headers
: "No headers configured"}
</Text>
</Group> */}
{/* <Group gap="xs">
<Text c="#9A9A9A" size="sm">
Payload:
</Text>
<Text c="#EAEAEA" size="sm" fw={500}>
{Object.keys(webhook.payload || {}).length
? webhook.payload
: "Empty payload"}
</Text>
</Group> */}
</Stack>
</Card>
))}
</Stack>
<Stack gap="sm">
<DetailRow
icon={IconCode}
label="Method"
value={webhook.method}
color="blue"
/>
<DetailRow
icon={IconLink}
label="Endpoint"
value={webhook.url}
color="teal"
isLink
/>
<DetailRow
icon={IconKey}
label="Token"
value={webhook.apiToken ? `${webhook.apiToken.slice(0, 12)}...` : "None"}
color="violet"
/>
</Stack>
</Card>
))}
</SimpleGrid>
)}
</Stack>
);
}
function DetailRow({ icon: Icon, label, value, color, isLink }: any) {
return (
<Group gap="xs" wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color={color} size="sm" radius="sm">
<Icon size={14} />
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Text fz="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Text
fz="sm"
fw={600}
component={isLink ? "a" : "div"}
href={isLink ? value : undefined}
target={isLink ? "_blank" : undefined}
style={{
wordBreak: "break-all",
display: "flex",
alignItems: "center",
gap: rem(4),
color: isLink ? "var(--mantine-color-teal-filled)" : "inherit",
}}
>
{value}
{isLink && <IconExternalLink size={12} />}
</Text>
</Box>
</Group>
);
}

View File

@@ -1,18 +1,5 @@
import {
Button,
Group,
Stack,
Title,
Tooltip,
Divider,
Container,
Paper,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useNavigate, Outlet } from "react-router-dom";
import { Outlet } from "react-router-dom";
export default function WebhookLayout() {
const navigate = useNavigate();
return <Outlet />;
}

View File

@@ -1,22 +1,140 @@
import apiFetch from "@/lib/apiFetch";
import { ReactQRCode } from "@lglab/react-qr-code";
import { Card, Container, Group } from "@mantine/core";
import {
Card,
Container,
Group,
Stack,
Title,
Text,
Paper,
Box,
Button,
ThemeIcon,
List,
Center,
Loader,
rem,
} from "@mantine/core";
import {
IconBrandWhatsapp,
IconDeviceMobile,
IconSettings,
IconQrcode,
IconArrowLeft,
IconCircleCheck,
} from "@tabler/icons-react";
import useSWR from "swr";
import { useNavigate, Navigate } from "react-router-dom";
import clientRoutes from "@/clientRoutes";
export default function QrcodePage() {
const { data } = useSWR("/wa/qr", apiFetch.api.wa.qr.get, {
const navigate = useNavigate();
const { data, isLoading } = useSWR("/wa/qr", apiFetch.api.wa.qr.get, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshInterval: 3000,
});
const { data: stateData } = useSWR("/wa/state", apiFetch.api.wa.state.get, {
refreshInterval: 3000,
});
// Redirect to dashboard if already connected
if (stateData?.data?.state?.ready) {
return <Navigate to={clientRoutes["/sq/dashboard/wajs/wajs-home"]} replace />;
}
const qrValue = data?.data?.qr;
return (
<Container size={"sm"}>
<h1>QrCode</h1>
<Group>
<Card bg={"white"}>
<ReactQRCode size={256} value={data?.data?.qr || ""} />
</Card>
</Group>
</Container>
<Box
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--mantine-color-dark-9)",
}}
>
<Container size={500}>
<Stack gap="xl">
<Center>
<Stack align="center" gap="xs">
<ThemeIcon size={60} radius="xl" color="green" variant="light">
<IconBrandWhatsapp size={40} />
</ThemeIcon>
<Title order={2} fw={900}>
Link WhatsApp Device
</Title>
<Text c="dimmed" size="sm" ta="center">
Scan the QR code below to connect your WhatsApp account to the server.
</Text>
</Stack>
</Center>
<Paper withBorder shadow="xl" p={40} radius="lg">
<Stack gap="xl" align="center">
<Box
p="md"
bg="white"
style={{
borderRadius: rem(12),
boxShadow: "0 0 20px rgba(0,0,0,0.1)",
}}
>
{qrValue ? (
<ReactQRCode size={256} value={qrValue} />
) : (
<Center w={256} h={256}>
<Stack align="center" gap="sm">
<Loader color="green" size="md" type="dots" />
<Text size="xs" c="dark" fw={600}>
Generating QR Code...
</Text>
</Stack>
</Center>
)}
</Box>
<Box w="100%">
<Text fw={700} size="sm" mb="md">
How to connect:
</Text>
<List
spacing="sm"
size="sm"
center
icon={
<ThemeIcon color="green" size={20} radius="xl">
<IconCircleCheck size={12} />
</ThemeIcon>
}
>
<List.Item>Open WhatsApp on your phone</List.Item>
<List.Item>
Tap <Text span fw={700}>Menu</Text> or <Text span fw={700}>Settings</Text> and select <Text span fw={700}>Linked Devices</Text>
</List.Item>
<List.Item>Tap on <Text span fw={700}>Link a Device</Text></List.Item>
<List.Item>Point your phone to this screen to capture the code</List.Item>
</List>
</Box>
</Stack>
</Paper>
<Center>
<Button
variant="subtle"
color="gray"
leftSection={<IconArrowLeft size={16} />}
onClick={() => navigate(clientRoutes["/sq/dashboard/wajs/wajs-home"])}
>
Back to Dashboard
</Button>
</Center>
</Stack>
</Container>
</Box>
);
}

View File

@@ -8,239 +8,239 @@ import { prisma } from '../prisma';
type HookData =
| { eventType: "qr"; qr: string }
| { eventType: "start" }
| { eventType: "ready" }
| { eventType: "disconnected"; reason?: string }
| { eventType: "reconnect" }
| { eventType: "auth_failure"; msg: string }
| { eventType: "message" } & Partial<WAWebJS.Message>;
| { eventType: "qr"; qr: string }
| { eventType: "start" }
| { eventType: "ready" }
| { eventType: "disconnected"; reason?: string }
| { eventType: "reconnect" }
| { eventType: "auth_failure"; msg: string }
| { eventType: "message" } & Partial<WAWebJS.Message>;
async function handleHook(data: HookData) {
const webHooks = await prisma.webHook.findMany({ where: { enabled: true } });
if (webHooks.length === 0) return;
await Promise.allSettled(
webHooks.map(async (hook) => {
try {
log(`🌐 Mengirim webhook ke ${hook.name} ${hook.url}`);
const webHooks = await prisma.webHook.findMany({ where: { enabled: true } });
if (webHooks.length === 0) return;
await Promise.allSettled(
webHooks.map(async (hook) => {
try {
log(`🌐 Mengirim webhook ke ${hook.name} ${hook.url}`);
let res: Response = {} as Response;
res = await fetch(hook.url, {
method: hook.method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${hook.apiToken}`,
},
body: JSON.stringify(data),
});
let res: Response = {} as Response;
res = await fetch(hook.url, {
method: hook.method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${hook.apiToken}`,
},
body: JSON.stringify(data),
});
const json = await res.text();
logger.info(`[RESPONSE] ${hook.name} ${hook.url}: ${json}`);
} catch (err) {
logger.error(`[ERROR] ${hook.name} ${hook.url}:`);
logger.error(`[ERROR] ${hook.name}: ${err}`);
}
})
)
const json = await res.text();
logger.info(`[RESPONSE] ${hook.name} ${hook.url}: ${json}`);
} catch (err) {
logger.error(`[ERROR] ${hook.name} ${hook.url}:`);
logger.error(`[ERROR] ${hook.name}: ${err}`);
}
})
)
}
// === STATE GLOBAL ===
const state = {
client: null as Client | null,
reconnectTimeout: null as NodeJS.Timeout | null,
isReconnecting: false,
isStarting: false,
qr: null as string | null,
ready: false,
async restart() {
log('🔄 Restart manual diminta...');
await destroyClient();
await startClient();
},
client: null as Client | null,
reconnectTimeout: null as NodeJS.Timeout | null,
isReconnecting: false,
isStarting: false,
qr: null as string | null,
ready: false,
async restart() {
log('🔄 Restart manual diminta...');
await destroyClient();
await startClient();
},
async forceStart() {
log('⚠️ Force start — menghapus cache dan session auth...');
await destroyClient();
await safeRm("./.wwebjs_auth");
await safeRm("./wwebjs_cache");
await startClient();
},
async stop() {
log('🛑 Stop manual diminta...');
await destroyClient();
},
async forceStart() {
log('⚠️ Force start — menghapus cache dan session auth...');
await destroyClient();
await safeRm("./.wwebjs_auth");
await safeRm("./wwebjs_cache");
await startClient();
},
async stop() {
log('🛑 Stop manual diminta...');
await destroyClient();
},
};
// === UTIL ===
function log(...args: any[]) {
console.log(`[${new Date().toISOString()}]`, ...args);
console.log(`[${new Date().toISOString()}]`, ...args);
}
async function safeRm(path: string) {
try {
await fs.rm(path, { recursive: true, force: true });
} catch (err) {
log(`⚠️ Gagal hapus ${path}:`, err);
}
try {
await fs.rm(path, { recursive: true, force: true });
} catch (err) {
log(`⚠️ Gagal hapus ${path}:`, err);
}
}
// === CLEANUP CLIENT ===
async function destroyClient() {
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
if (state.client) {
try {
state.client.removeAllListeners();
await state.client.destroy();
log('🧹 Client lama dihentikan & listener dibersihkan');
} catch (err) {
log('⚠️ Gagal destroy client:', err);
}
state.client = null;
state.ready = false;
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
if (state.client) {
try {
state.client.removeAllListeners();
await state.client.destroy();
log('🧹 Client lama dihentikan & listener dibersihkan');
} catch (err) {
log('⚠️ Gagal destroy client:', err);
}
state.client = null;
state.ready = false;
}
}
let connectedAt: number | null = null;
// === PEMBUATAN CLIENT ===
async function startClient() {
if (state.isStarting || state.isReconnecting) {
log('⏳ startClient diabaikan — proses sedang berjalan...');
return;
if (state.isStarting || state.isReconnecting) {
log('⏳ startClient diabaikan — proses sedang berjalan...');
return;
}
state.isStarting = true;
await destroyClient();
log('🚀 Memulai WhatsApp client...');
handleHook({ eventType: "start" });
const client = new Client({
authStrategy: new LocalAuth({
dataPath: process.env.WWEBJS_AUTH || path.join(process.cwd(), '.wwebjs_auth')
}),
puppeteer: {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
},
webVersionCache: {
path: process.env.WWEBJS_CACHE || path.join(process.cwd(), '.wwebjs_cache'),
type: 'local',
}
state.isStarting = true;
});
await destroyClient();
state.client = client;
log('🚀 Memulai WhatsApp client...');
handleHook({ eventType: "start" });
// === EVENT LISTENERS ===
client.on('qr', (qr) => {
state.qr = qr;
qrcode.generate(qr, { small: true });
log('🔑 QR code baru diterbitkan');
handleHook({ eventType: "qr", qr });
});
const client = new Client({
authStrategy: new LocalAuth({
dataPath: process.env.WWEBJS_AUTH || path.join(process.cwd(), '.wwebjs_auth')
}),
puppeteer: {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
},
webVersionCache: {
path: process.env.WWEBJS_CACHE || path.join(process.cwd(), '.wwebjs_cache'),
type: 'local',
}
});
state.client = client;
// === EVENT LISTENERS ===
client.on('qr', (qr) => {
state.qr = qr;
qrcode.generate(qr, { small: true });
log('🔑 QR code baru diterbitkan');
handleHook({ eventType: "qr", qr });
});
client.on('ready', () => {
connectedAt = Date.now();
log('✅ WhatsApp client siap digunakan!');
state.ready = true;
state.isReconnecting = false;
state.isStarting = false;
state.qr = null;
handleHook({ eventType: "ready" });
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
});
client.on('auth_failure', (msg) => {
log('❌ Autentikasi gagal:', msg);
state.ready = false;
handleHook({ eventType: "auth_failure", msg });
});
client.on('disconnected', async (reason) => {
log('⚠️ Client terputus:', reason);
state.ready = false;
handleHook({ eventType: "disconnected", reason });
if (state.reconnectTimeout) clearTimeout(state.reconnectTimeout);
state.isReconnecting = true;
log('⏳ Mencoba reconnect dalam 5 detik...');
state.reconnectTimeout = setTimeout(async () => {
handleHook({ eventType: "reconnect" });
await startClient();
}, 5000);
});
client.on('message', handleIncomingMessage);
// === INISIALISASI ===
try {
await client.initialize();
} catch (err) {
log('❌ Gagal inisialisasi client:', err);
log('⏳ Mencoba reconnect dalam 10 detik...');
state.reconnectTimeout = setTimeout(async () => {
state.isReconnecting = false;
await startClient();
}, 10000);
handleHook({ eventType: "reconnect" });
} finally {
state.isStarting = false;
client.on('ready', () => {
connectedAt = Date.now();
log('✅ WhatsApp client siap digunakan!');
state.ready = true;
state.isReconnecting = false;
state.isStarting = false;
state.qr = null;
handleHook({ eventType: "ready" });
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
});
client.on('auth_failure', (msg) => {
log('❌ Autentikasi gagal:', msg);
state.ready = false;
handleHook({ eventType: "auth_failure", msg });
});
client.on('disconnected', async (reason) => {
log('⚠️ Client terputus:', reason);
state.ready = false;
handleHook({ eventType: "disconnected", reason });
if (state.reconnectTimeout) clearTimeout(state.reconnectTimeout);
state.isReconnecting = true;
log('⏳ Mencoba reconnect dalam 5 detik...');
state.reconnectTimeout = setTimeout(async () => {
handleHook({ eventType: "reconnect" });
await startClient();
}, 5000);
});
client.on('message', handleIncomingMessage);
// === INISIALISASI ===
try {
await client.initialize();
} catch (err) {
log('❌ Gagal inisialisasi client:', err);
log('⏳ Mencoba reconnect dalam 10 detik...');
state.reconnectTimeout = setTimeout(async () => {
state.isReconnecting = false;
await startClient();
}, 10000);
handleHook({ eventType: "reconnect" });
} finally {
state.isStarting = false;
}
}
// === HANDLER PESAN MASUK ===
async function handleIncomingMessage(msg: WAWebJS.Message) {
const chat = await msg.getChat();
const chat = await msg.getChat();
// await chat.sendStateTyping();
log(`💬 Pesan dari ${msg.from}: ${msg.body || '[MEDIA]'}`);
// await chat.sendStateTyping();
log(`💬 Pesan dari ${msg.from}: ${msg.body || '[MEDIA]'}`);
if (!connectedAt) return;
if (msg.timestamp * 1000 < connectedAt) return;
if (!connectedAt) return;
if (msg.timestamp * 1000 < connectedAt) return;
if (msg.from.endsWith('@g.us') || msg.isStatus || msg.from === 'status@broadcast') {
log(`🚫 Pesan dari grup/status diabaikan (${msg.from})`);
return;
}
if (msg.from.endsWith('@g.us') || msg.isStatus || msg.from === 'status@broadcast') {
log(`🚫 Pesan dari grup/status diabaikan (${msg.from})`);
return;
}
if (msg.hasMedia) {
const media = await msg.downloadMedia();
(msg as any).media = media;
}
if (msg.hasMedia) {
const media = await msg.downloadMedia();
(msg as any).media = media;
}
handleHook({ eventType: "message", ...msg })
handleHook({ eventType: "message", ...msg })
}
// === CLEANUP SAAT EXIT ===
process.on('SIGINT', () => {
log('🛑 SIGINT diterima, menutup client...');
destroyClient().then(() => {
process.exit(0);
}).catch((err) => {
log('⚠️ Error saat destroyClient:', err);
process.exit(1);
});
log('🛑 SIGINT diterima, menutup client...');
destroyClient().then(() => {
process.exit(0);
}).catch((err) => {
log('⚠️ Error saat destroyClient:', err);
process.exit(1);
});
});
@@ -249,5 +249,5 @@ const getState = () => state;
export { destroyClient, getState, startClient };
if (import.meta.main) {
await startClient();
await startClient();
}

View File

@@ -4,6 +4,11 @@ import _ from "lodash";
import mime from "mime-types";
import { MessageMedia } from "whatsapp-web.js";
const formatJid = (num: string) => {
if (num.includes("@")) return num;
return `${num}@c.us`;
};
const WaRoute = new Elysia({
prefix: "/wa",
tags: ["WhatsApp"]
@@ -66,7 +71,7 @@ const WaRoute = new Elysia({
}
const chat = await client.getChatById(`${body.number}@c.us`);
const chat = await client.getChatById(formatJid(body.number));
await chat.sendMessage(body.text);
return {
@@ -96,7 +101,7 @@ const WaRoute = new Elysia({
try {
const { number, caption, media } = body;
const jid = `${number}@c.us`;
const jid = formatJid(number);
// Siapkan data media
const { data, filename, mimetype } = media;
@@ -138,7 +143,7 @@ const WaRoute = new Elysia({
},
{
body: t.Object({
number: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"] }),
number: t.String({ minLength: 5, maxLength: 50, examples: ["6281234567890", "1234567890@lid"] }),
caption: t.Optional(t.String({ maxLength: 255, examples: ["Hello World"] })),
media: t.Object({
data: t.String({ examples: ["iVBORw0KGgoAAAANSUhEUgAAAAEAAAABC..."], description: "Base64 encoded media data" }),
@@ -179,14 +184,14 @@ const WaRoute = new Elysia({
};
}
const chat = await state.client.sendMessage(`${nom}@c.us`, text);
const chat = await state.client.sendMessage(formatJid(nom as string), text as string);
return {
message: "✅ Message sent",
info: chat.id,
};
}, {
query: t.Object({
nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"] }),
nom: t.String({ minLength: 5, maxLength: 50, examples: ["6281234567890", "1234567890@lid"] }),
text: t.String({ examples: ["Hello World"] }),
}),
detail: {
@@ -221,7 +226,7 @@ const WaRoute = new Elysia({
};
}
const chat = await state.client.getChatById(`${nom}@c.us`);
const chat = await state.client.getChatById(formatJid(nom as string));
// await chat.sendSeen();
return {
message: "✅ Seen sent",
@@ -229,7 +234,7 @@ const WaRoute = new Elysia({
};
}, {
query: t.Object({
nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"] }),
nom: t.String({ minLength: 5, maxLength: 50, examples: ["6281234567890", "1234567890@lid"] }),
}),
detail: {
summary: "Send seen to WhatsApp",
@@ -263,7 +268,7 @@ const WaRoute = new Elysia({
};
}
const chat = await state.client.getChatById(`${nom}@c.us`);
const chat = await state.client.getChatById(formatJid(nom as string));
await chat.sendStateTyping();
return {
message: "✅ Typing sent",
@@ -271,7 +276,7 @@ const WaRoute = new Elysia({
};
}, {
query: t.Object({
nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"] }),
nom: t.String({ minLength: 5, maxLength: 50, examples: ["6281234567890", "1234567890@lid"] }),
}),
detail: {
summary: "Send typing to WhatsApp",

39
x.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Client, LocalAuth } from "whatsapp-web.js";
import path from "path";
import qrcode from 'qrcode-terminal';
const client = new Client({
authStrategy: new LocalAuth(),
puppeteer: {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
},
webVersionCache: {
path: process.env.WWEBJS_CACHE || path.join(process.cwd(), '.wwebjs_cache'),
type: 'local',
}
});
client.on('qr', (qr: string) => {
// Generate and scan this code with your phone
console.log('QR RECEIVED', qr);
qrcode.generate(qr, { small: true });
});
client.on('ready', () => {
console.log('Client is ready!');
});
client.on('message', (msg: any) => {
if (msg.body == '!ping') {
msg.reply('pong');
}
});
client.initialize();

218
x.tsx
View File

@@ -1,218 +0,0 @@
// import Elysia, { t, type Context } from "elysia";
// import { startClient, getState } from "../lib/wa/wa_service";
// import _ from "lodash";
// import mime from "mime-types";
// import { MessageMedia } from "whatsapp-web.js";
// const checkClientReady = () => {
// /**
// * Mengecek kesiapan klien WhatsApp.
// * Fungsi ini mengambil state saat ini dari WhatsApp service dan memeriksa
// * apakah klien sudah siap dan terhubung ke WhatsApp Web.
// *
// * @returns {Object} - Objek dengan properti client jika klien siap,
// * atau error dan status jika klien belum siap
// */
// const state = getState();
// if (!state.ready || !state.client) return { error: "WhatsApp client is not ready", status: 400 };
// return { client: state.client };
// };
// const WaRoute = new Elysia({
// prefix: "/wa",
// tags: ["WhatsApp"]
// })
// .post("/start", () => {
// startClient();
// return { message: "WhatsApp route started" };
// }, {
// detail: {
// summary: "Start WhatsApp Client",
// description: "Initialize and start the WhatsApp Web client connection"
// }
// })
// .get("/qr", () => ({ qr: getState().qr }), {
// detail: {
// summary: "Get QR Code",
// description: "Retrieve the current QR code for WhatsApp Web authentication. Scan this QR code with your WhatsApp mobile app to connect."
// }
// })
// .get("/ready", () => ({ ready: getState().ready }), {
// detail: {
// summary: "Check Ready Status",
// description: "Check if the WhatsApp client is ready and authenticated"
// }
// })
// .post("/restart", () => {
// getState().restart();
// return { message: "WhatsApp route restarted" };
// }, {
// detail: {
// summary: "Restart WhatsApp Client",
// description: "Restart the WhatsApp Web client connection. This will disconnect and reconnect the client."
// }
// })
// .post("/force-start", () => {
// getState().forceStart();
// return { message: "WhatsApp route force started" };
// }, {
// detail: {
// summary: "Force Start WhatsApp Client",
// description: "Force start the WhatsApp Web client, bypassing any existing connection checks"
// }
// })
// .post("/stop", () => {
// getState().stop();
// return { message: "WhatsApp route stopped" };
// }, {
// detail: {
// summary: "Stop WhatsApp Client",
// description: "Stop and disconnect the WhatsApp Web client"
// }
// })
// .get("/state", () => ({ state: _.omit(getState(), "client") }), {
// detail: {
// summary: "Get Client State",
// description: "Retrieve the current state of the WhatsApp client including connection status, QR code availability, and other metadata (excludes client object)"
// }
// })
// .post("/send-text", async ({ body }) => {
// const check = checkClientReady();
// if (check.error) return { message: check.error };
// const chat = await check.client!.getChatById(`${body.number}@c.us`);
// await chat.sendMessage(body.text);
// return { success: true, message: chat.id };
// }, {
// body: t.Object({
// number: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Recipient phone number in international format without + sign" }),
// text: t.String({ minLength: 1, examples: ["Hello World"], description: "Text message content to send" }),
// }),
// detail: {
// summary: "Send Text Message",
// description: "Send a text message to a WhatsApp contact. The phone number should be in international format without the + sign (e.g., 6281234567890 for Indonesia)."
// }
// })
// .post("/send-media", async ({ body }) => {
// const check = checkClientReady();
// if (check.error) return { message: check.error };
// try {
// const { number, caption, media } = body;
// const { data, filename, mimetype } = media;
// const mimeType = mimetype || mime.lookup(filename) || "application/octet-stream";
// const fileName = filename || `file.${mime.extension(mimeType) || "bin"}`;
// const waMedia = new MessageMedia(mimeType, data, fileName);
// const sendOptions: any = { caption };
// if (mimeType.startsWith("audio/")) {
// sendOptions.sendAudioAsVoice = mimeType.includes("ogg") || mimeType.includes("opus");
// } else if (!mimeType.startsWith("image/") && !mimeType.startsWith("video/")) {
// sendOptions.sendMediaAsDocument = true;
// }
// await check.client!.sendMessage(`${number}@c.us`, waMedia, sendOptions);
// return {
// success: true,
// message: `✅ Media sent to ${number}`,
// info: { filename: fileName, mimetype: mimeType },
// };
// } catch (err: any) {
// return { success: false, message: "❌ Failed to send media", error: err.message };
// }
// }, {
// body: t.Object({
// number: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Recipient phone number in international format without + sign" }),
// caption: t.Optional(t.String({ maxLength: 255, examples: ["Hello World"], description: "Optional caption for the media" })),
// media: t.Object({
// data: t.String({ examples: ["iVBORw0KGgoAAAANSUhEUgAAAAEAAAABC..."], description: "Base64 encoded media data" }),
// filename: t.String({ minLength: 1, maxLength: 255, examples: ["file.png"], description: "Original filename with extension" }),
// mimetype: t.String({ minLength: 1, maxLength: 255, examples: ["image/png"], description: "MIME type of the media file" }),
// }, { description: "Media object containing base64 data, filename, and mimetype" }),
// }),
// detail: {
// summary: "Send Media Message",
// description: "Send media (image, audio, video, PDF, or any file) to a WhatsApp contact. Audio files (ogg/opus) are sent as voice messages. Non-image/video files are sent as documents."
// }
// })
// .get("/code", async (ctx: Context) => {
// const { nom, text } = ctx.query;
// if (!nom || !text) {
// ctx.set.status = 400;
// return { message: "[QUERY] Nomor dan teks harus diisi" };
// }
// const check = checkClientReady();
// if (check.error) {
// ctx.set.status = 400;
// return { message: `[READY] ${check.error}` };
// }
// const chat = await check.client!.sendMessage(`${nom}@c.us`, text);
// return { message: "✅ Message sent", info: chat.id };
// }, {
// query: t.Object({
// nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Recipient phone number in international format without + sign" }),
// text: t.String({ examples: ["Hello World"], description: "Text message content to send" }),
// }),
// detail: {
// summary: "Send Text via GET",
// description: "Send a text message to a WhatsApp contact using GET request with query parameters. Useful for simple integrations or webhooks."
// }
// })
// .post("/send-seen", async (ctx: Context) => {
// const { nom } = ctx.query;
// if (!nom) {
// ctx.set.status = 400;
// return { message: "[QUERY] Nomor harus diisi" };
// }
// const check = checkClientReady();
// if (check.error) {
// ctx.set.status = 400;
// return { message: `[READY] ${check.error}` };
// }
// const chat = await check.client!.getChatById(`${nom}@c.us`);
// // await chat.sendSeen();
// return { message: "✅ Seen sent", info: chat.id };
// }, {
// query: t.Object({
// nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Phone number of the chat to mark as seen" }),
// }),
// detail: {
// summary: "Mark Chat as Seen",
// description: "Mark all messages in a chat as seen/read. This will show blue ticks to the sender indicating the messages have been read."
// }
// })
// .post("/send-typing", async ({ query, set }) => {
// if (!query.nom) {
// set.status = 400;
// return { message: "[QUERY] Nomor harus diisi" };
// }
// const check = checkClientReady();
// if (check.error) {
// set.status = 400;
// return { message: `[READY] ${check.error}` };
// }
// const chat = await check.client!.getChatById(`${query.nom}@c.us`);
// await chat.sendStateTyping();
// return { message: "✅ Typing sent", info: chat.id };
// }, {
// query: t.Object({
// nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Phone number of the chat to show typing indicator" }),
// }),
// detail: {
// summary: "Send Typing Indicator",
// description: "Show 'typing...' indicator in a chat. The recipient will see that you are typing a message."
// }
// })
// export default WaRoute;
export {}