feat: complete lid format handling and UI updates

This commit is contained in:
bipproduction
2026-02-06 07:02:41 +08:00
parent 32610ebfd1
commit 0152229b96
24 changed files with 2719 additions and 1923 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

View File

@@ -11,6 +11,8 @@
"@elysiajs/swagger": "^1.3.1", "@elysiajs/swagger": "^1.3.1",
"@lglab/react-qr-code": "^1.4.9", "@lglab/react-qr-code": "^1.4.9",
"@mantine/core": "^8.3.13", "@mantine/core": "^8.3.13",
"@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.13", "@mantine/hooks": "^8.3.13",
"@mantine/modals": "^8.3.13", "@mantine/modals": "^8.3.13",
"@mantine/notifications": "^8.3.13", "@mantine/notifications": "^8.3.13",
@@ -49,7 +51,7 @@
"uuid": "^13.0.0", "uuid": "^13.0.0",
"whatsapp-api-js": "^6.2.1", "whatsapp-api-js": "^6.2.1",
"whatsapp-client-sdk": "^1.6.0", "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", "yaml": "^2.8.2",
}, },
"devDependencies": { "devDependencies": {
@@ -65,7 +67,7 @@
}, },
}, },
"packages": { "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=="], "@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/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/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=="], "@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=="], "@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=="], "@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-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=="], "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=="], "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=="], "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=="], "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=="], "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-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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],

View File

@@ -17,6 +17,8 @@
"@elysiajs/swagger": "^1.3.1", "@elysiajs/swagger": "^1.3.1",
"@lglab/react-qr-code": "^1.4.9", "@lglab/react-qr-code": "^1.4.9",
"@mantine/core": "^8.3.13", "@mantine/core": "^8.3.13",
"@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.13", "@mantine/hooks": "^8.3.13",
"@mantine/modals": "^8.3.13", "@mantine/modals": "^8.3.13",
"@mantine/notifications": "^8.3.13", "@mantine/notifications": "^8.3.13",
@@ -55,7 +57,7 @@
"uuid": "^13.0.0", "uuid": "^13.0.0",
"whatsapp-api-js": "^6.2.1", "whatsapp-api-js": "^6.2.1",
"whatsapp-client-sdk": "^1.6.0", "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" "yaml": "^2.8.2"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

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

View File

@@ -1,15 +1,163 @@
import clientRoutes from "@/clientRoutes"; 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"; 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() { export default function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Container> <Box bg="var(--mantine-color-body)" style={{ minHeight: "100vh" }}>
<h1>Home</h1> {/* Hero Section */}
<Button onClick={() => navigate(clientRoutes["/sq/dashboard"])}> <Container size="lg" pt={{ base: 80, md: 120 }} pb={80}>
Go to SQ <Stack align="center" gap="xl">
</Button> <ThemeIcon size={80} radius="xl" color="green" variant="light">
</Container> <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 { import {
Button, Button,
Container, Container,
Group, Paper,
PasswordInput,
Stack,
Text, Text,
TextInput, TextInput,
PasswordInput,
Group,
Stack,
Title,
Center,
Box,
ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { useEffect, useState } from "react"; 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 apiFetch from "../lib/apiFetch";
import clientRoutes from "@/clientRoutes"; import clientRoutes from "@/clientRoutes";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
export default function Login() { export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); 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(() => { useEffect(() => {
async function checkSession() { async function checkSession() {
try { try {
// backend otomatis baca cookie JWT dari request
const res = await apiFetch.api.user.find.get(); const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200); setIsAuthenticated(res.status === 200);
} catch { } catch {
@@ -32,54 +47,103 @@ export default function Login() {
checkSession(); checkSession();
}, []); }, []);
const handleSubmit = async () => { const handleSubmit = async (values: typeof form.values) => {
setLoading(true); setLoading(true);
try { try {
const response = await apiFetch.auth.login.post({ const response = await apiFetch.auth.login.post({
email, email: values.email,
password, password: values.password,
}); });
if (response.data?.token) { if (response.data?.token) {
localStorage.setItem("token", 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"]; window.location.href = clientRoutes["/sq/dashboard"];
return; return;
} }
if (response.error) { 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) { } catch (error) {
console.error(error); console.error(error);
notifications.show({
title: "Error",
message: "An unexpected error occurred",
color: "red",
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (isAuthenticated === null) return null; // or loading spinner if (isAuthenticated === null) return null;
if (isAuthenticated) if (isAuthenticated)
return <Navigate to={clientRoutes["/sq/dashboard"]} replace />; return <Navigate to={clientRoutes["/sq/dashboard"]} replace />;
return ( return (
<Container> <Box
<Stack> style={{
<Text>Login</Text> height: "100vh",
<TextInput display: "flex",
placeholder="Email" flexDirection: "column",
value={email} justifyContent: "center",
onChange={(e) => setEmail(e.target.value)} background: "var(--mantine-color-body)",
/> }}
<PasswordInput >
placeholder="Password" <Container size={420} my={40}>
value={password} <Stack gap="xs" mb={30} align="center">
onChange={(e) => setPassword(e.target.value)} <ThemeIcon size={60} radius="xl" color="green" variant="light">
/> <IconBrandWhatsapp size={40} />
<Group justify="right"> </ThemeIcon>
<Button onClick={handleSubmit} disabled={loading}> <Title ta="center" order={2} fw={900}>
Login Welcome Back!
</Button> </Title>
</Group> <Text c="dimmed" size="sm" ta="center">
</Stack> Login to manage your WhatsApp integration
</Container> </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 { import {
ActionIcon,
Badge,
Box,
Button, Button,
Card, Center,
Container, Container,
Divider,
Group, Group,
Loader,
Paper,
ScrollArea,
Stack, Stack,
Table, Table,
Text, Text,
TextInput, TextInput,
ScrollArea, Title,
Divider, Tooltip
Tooltip,
Badge,
Loader,
ActionIcon,
Center,
} from "@mantine/core"; } 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 { useEffect, useState } from "react";
import { showNotification } from "@mantine/notifications";
import apiFetch from "@/lib/apiFetch";
export default function ApiKeyPage() { export default function ApiKeyPage() {
const [refresh, setRefresh] = useState(false);
const toggleRefresh = () => setRefresh((r) => !r);
return ( return (
<Container <Container w="100%" size="xl" py="xl">
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",
}}
>
<Stack gap="xl"> <Stack gap="xl">
<Group justify="space-between"> <Box>
<Group gap="xs"> <Group justify="space-between" align="flex-end">
<IconKey size={28} color="#00FFC8" /> <Stack gap={4}>
<Text fw={700} fz={26} c="#EAEAEA"> <Group gap="xs">
API Key Management <IconKey size={32} color="var(--mantine-color-teal-filled)" />
</Text> <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> </Group>
<Badge <Divider mt="md" variant="dotted" />
size="lg" </Box>
radius="lg"
style={{ <CreateApiKey onCreated={toggleRefresh} />
background: <ListApiKey refresh={refresh} />
"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 />
</Stack> </Stack>
</Container> </Container>
); );
} }
function CreateApiKey() { function CreateApiKey({ onCreated }: { onCreated: () => void }) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [expiredAt, setExpiredAt] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [refresh, setRefresh] = useState(false);
const handleSubmit = async (e: React.FormEvent) => { const form = useForm({
e.preventDefault(); initialValues: {
if (!name.trim()) { name: "",
showNotification({ description: "",
title: "Missing name", expiredAt: null as Date | null,
message: "Please enter a name for your API key", },
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", color: "red",
}); });
return; } finally {
} setLoading(false);
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);
} }
}; };
return ( return (
<Stack gap="xl"> <Paper withBorder shadow="sm" p="xl" radius="md">
<Card <Stack gap="md">
p="xl" <Group justify="space-between">
radius="lg" <Text fw={700} fz="lg">
style={{ Create New API Key
background: </Text>
"linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01))", <IconPlus size={20} color="var(--mantine-color-dimmed)" />
border: "1px solid rgba(0,255,200,0.1)", </Group>
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>
<ListApiKey refresh={refresh} /> <form onSubmit={form.onSubmit(handleSubmit)}>
</Stack> <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 }) { function ListApiKey({ refresh }: { refresh: boolean }) {
const [apiKeys, setApiKeys] = useState<any[]>([]); const [apiKeys, setApiKeys] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => { const fetchApiKeys = async () => {
const fetchApiKeys = async () => { setLoading(true);
setLoading(true); try {
const res = await apiFetch.api.apikey.list.get(); const res = await apiFetch.api.apikey.list.get();
if (res.status === 200) { if (res.status === 200) {
setApiKeys(res.data?.apiKeys || []); setApiKeys(res.data?.apiKeys || []);
} }
} catch (err) {
console.error(err);
} finally {
setLoading(false); setLoading(false);
}; }
};
useEffect(() => {
fetchApiKeys(); fetchApiKeys();
}, [refresh]); }, [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 ( return (
<Card <Paper withBorder shadow="sm" p="xl" radius="md">
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"> <Stack gap="md">
<Group justify="space-between"> <Group justify="space-between">
<Text fw={600} fz="lg" c="#EAEAEA"> <Text fw={700} fz="lg">
Active API Keys Active API Keys
</Text> </Text>
<TextInput
placeholder="Search keys..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.target.value)}
size="xs"
w={250}
/>
</Group> </Group>
<Divider color="rgba(0,255,200,0.05)" />
<Divider variant="dotted" />
{loading ? ( {loading ? (
<Center py="xl"> <Center py={50}>
<Loader color="teal" /> <Stack align="center" gap="sm">
<Loader color="teal" size="lg" type="dots" />
<Text c="dimmed" fz="sm">
Fetching your keys...
</Text>
</Stack>
</Center> </Center>
) : apiKeys.length === 0 ? ( ) : filteredKeys.length === 0 ? (
<Center py="xl"> <Center py={50}>
<Text c="#9A9A9A">No API keys found</Text> <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> </Center>
) : ( ) : (
<ScrollArea> <ScrollArea>
<Table <Table highlightOnHover verticalSpacing="md">
highlightOnHover
verticalSpacing="sm"
horizontalSpacing="md"
style={{
color: "#EAEAEA",
borderCollapse: "separate",
borderSpacing: "0 8px",
}}
>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Name</Table.Th> <Table.Th>Name</Table.Th>
<Table.Th>Description</Table.Th> <Table.Th>Description</Table.Th>
<Table.Th>Expired</Table.Th> <Table.Th>Expiration</Table.Th>
<Table.Th>Created</Table.Th> <Table.Th>Created</Table.Th>
<Table.Th>Updated</Table.Th> <Table.Th align="right" style={{ textAlign: "right" }}>
<Table.Th align="right">Actions</Table.Th> Actions
</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{apiKeys.map((apiKey: any, index: number) => ( {filteredKeys.map((apiKey: any) => (
<Table.Tr <Table.Tr key={apiKey.id}>
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>
<Table.Td> <Table.Td>
{apiKey.expiredAt <Text fw={600} fz="sm">
? new Date(apiKey.expiredAt).toISOString().split("T")[0] {apiKey.name}
: "—"} </Text>
</Table.Td> </Table.Td>
<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>
<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>
<Table.Td align="right"> <Table.Td>
<Group gap={4} justify="right"> <Text fz="sm">{dayjs(apiKey.createdAt).format("MMM DD, YYYY")}</Text>
<Tooltip label="Copy Key" withArrow> </Table.Td>
<Table.Td>
<Group gap={8} justify="right">
<Tooltip label="Copy API Key" withArrow position="top">
<ActionIcon <ActionIcon
variant="light" variant="light"
color="teal" color="teal"
onClick={() => { onClick={() => handleCopy(apiKey.key)}
navigator.clipboard.writeText(apiKey.key); size="lg"
showNotification({
title: "Copied",
message: "API key copied to clipboard",
color: "teal",
});
}}
> >
<IconCopy size={18} /> <IconCopy size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Delete Key" withArrow> <Tooltip label="Delete API Key" withArrow position="top">
<ActionIcon <ActionIcon
variant="light" variant="light"
color="red" color="red"
onClick={async () => { onClick={() => handleDelete(apiKey.id)}
await apiFetch.api.apikey.delete.delete({ size="lg"
id: apiKey.id,
});
setApiKeys((prev) =>
prev.filter((a) => a.id !== apiKey.id),
);
showNotification({
title: "Deleted",
message: "API key removed successfully",
color: "red",
});
}}
> >
<IconTrash size={18} /> <IconTrash size={18} />
</ActionIcon> </ActionIcon>
@@ -314,6 +350,6 @@ function ListApiKey({ refresh }: { refresh: boolean }) {
</ScrollArea> </ScrollArea>
)} )}
</Stack> </Stack>
</Card> </Paper>
); );
} }

View File

@@ -1,177 +1,113 @@
import { useEffect, useState } from "react"; import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
import { import {
ActionIcon,
AppShell, AppShell,
Avatar, Avatar,
Button, Box,
Card, Burger,
Divider, Divider,
Flex,
Group, Group,
Menu,
NavLink, NavLink,
Paper,
ScrollArea, ScrollArea,
Stack, Stack,
Text, Text,
ThemeIcon,
Title, Title,
Tooltip, UnstyledButton,
Badge, rem
} from "@mantine/core"; } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
IconChevronLeft, IconBrandWhatsapp,
IconChevronDown,
IconChevronRight, IconChevronRight,
IconDashboard, IconDashboard,
IconHome,
IconKey, IconKey,
IconWebhook,
IconBrandWhatsapp,
IconUser,
IconLogout, IconLogout,
IconSettings,
IconWebhook,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import type { User } from "generated/prisma"; import type { User } from "generated/prisma";
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; 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() { export default function DashboardLayout() {
const [opened, setOpened] = useLocalStorage({ const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
key: "nav_open", const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
defaultValue: true, const location = useLocation();
});
return ( return (
<AppShell <AppShell
padding="lg" header={{ height: 60 }}
navbar={{ navbar={{
width: 270, width: 280,
breakpoint: "sm", breakpoint: "sm",
collapsed: { mobile: !opened, desktop: !opened }, collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
styles={{
main: {
background: "#191919",
color: "#EAEAEA",
},
}} }}
padding="md"
> >
<AppShell.Navbar <AppShell.Header p="md">
p="md" <Group h="100%" justify="space-between">
style={{ <Group>
background: "rgba(30,30,30,0.8)", <Burger
backdropFilter: "blur(10px)", opened={mobileOpened}
borderRight: "1px solid rgba(0,255,200,0.15)", onClick={toggleMobile}
// boxShadow: "0 0 18px rgba(0,255,200,0.1)", hiddenFrom="sm"
}} size="sm"
> />
<AppShell.Section> <Burger
<Group justify="flex-end" p="xs"> opened={desktopOpened}
<Tooltip onClick={toggleDesktop}
label={opened ? "Collapse navigation" : "Expand navigation"} visibleFrom="sm"
withArrow size="sm"
color="cyan" />
> <Group gap="xs">
<ActionIcon <ThemeIcon variant="light" color="teal" size="sm">
variant="light" <IconBrandWhatsapp size={18} />
radius="xl" </ThemeIcon>
onClick={() => setOpened((v) => !v)} <Title order={4} fw={800} visibleFrom="xs">
aria-label="Toggle navigation" WAJS SERVER
style={{ </Title>
color: "#00FFC8", </Group>
background: "rgba(0,255,200,0.1)",
// boxShadow: "0 0 10px rgba(0,255,200,0.2)",
}}
>
{opened ? <IconChevronLeft /> : <IconChevronRight />}
</ActionIcon>
</Tooltip>
</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 /> <NavigationDashboard />
</AppShell.Section> </AppShell.Section>
<AppShell.Section> <AppShell.Section>
<HostView /> <Divider my="sm" />
<NavigationFooter />
</AppShell.Section> </AppShell.Section>
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main> <AppShell.Main bg="var(--mantine-color-dark-9)">
<Stack gap="md"> <Box
<Paper style={{
withBorder maxWidth: 1200,
shadow="lg" margin: "0 auto",
radius="xl" width: "100%",
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>
<Outlet /> <Outlet />
</Stack> </Box>
</AppShell.Main> </AppShell.Main>
</AppShell> </AppShell>
); );
} }
function HostView() { function HostHeaderView() {
const [host, setHost] = useState<User | null>(null); const [host, setHost] = useState<User | null>(null);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
async function fetchHost() { async function fetchHost() {
@@ -181,51 +117,49 @@ function HostView() {
fetchHost(); fetchHost();
}, []); }, []);
const handleLogout = async () => {
await apiFetch.auth.logout.delete();
localStorage.removeItem("token");
window.location.href = "/login";
};
if (!host) return null;
return ( return (
<Card <Menu shadow="md" width={200} position="bottom-end">
radius="xl" <Menu.Target>
withBorder <UnstyledButton>
shadow="md" <Group gap="xs">
p="md" <Avatar color="teal" radius="xl" size="sm">
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,
}}
>
{host.name?.[0]} {host.name?.[0]}
</Avatar> </Avatar>
<Stack gap={2}> <Box visibleFrom="sm" style={{ flex: 1 }}>
<Text fw={600} c="#EAEAEA"> <Text size="sm" fw={500}>
{host.name} {host.name}
</Text> </Text>
<Text size="sm" c="#9A9A9A"> </Box>
{host.email} <IconChevronDown size={14} color="var(--mantine-color-dimmed)" />
</Text> </Group>
</Stack> </UnstyledButton>
</Flex> </Menu.Target>
<Divider color="rgba(0,255,200,0.2)" />
<Logout /> <Menu.Dropdown>
</Stack> <Menu.Label>Application</Menu.Label>
) : ( <Menu.Item
<Text size="sm" c="#9A9A9A" ta="center"> leftSection={<IconSettings style={{ width: rem(14), height: rem(14) }} />}
Host data unavailable >
</Text> Settings
)} </Menu.Item>
</Card> <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", path: "/sq/dashboard/dashboard",
label: "Overview", label: "Overview",
icon: <IconDashboard size={20} color="#00FFFF" />, icon: <IconDashboard size={20} />,
desc: "Main dashboard insights", },
{
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", path: "/sq/dashboard/apikey/apikey",
label: "API Keys", label: "API Keys",
icon: <IconKey size={20} color="#00FFFF" />, icon: <IconKey size={20} />,
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",
}, },
]; ];
return ( return (
<Stack gap="xs"> <Stack gap={4}>
{items.map((item) => ( {items.map((item) => (
<NavLink <NavLink
key={item.path} key={item.path}
active={location.pathname.startsWith(item.path)} active={location.pathname.startsWith(item.path)}
leftSection={item.icon} leftSection={item.icon}
label={item.label} label={item.label}
description={item.desc}
onClick={() => onClick={() =>
navigate(clientRoutes[item.path as keyof typeof clientRoutes]) navigate(clientRoutes[item.path as keyof typeof clientRoutes])
} }
style={{ variant="light"
borderRadius: "12px", color="teal"
color: "#EAEAEA", style={{ borderRadius: rem(8) }}
background: location.pathname.startsWith(item.path) rightSection={<IconChevronRight size={14} stroke={1.5} />}
? "rgba(0,255,200,0.15)"
: "transparent",
transition: "background 0.2s ease",
}}
styles={{
label: { fontWeight: 500, color: "#EAEAEA" },
description: { color: "#9A9A9A" },
}}
/> />
))} ))}
</Stack> </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() { 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 ( return (
<div> <Stack gap="xl" py="sm">
<h1>Dashboard</h1> {/* Header Section */}
</div> <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 apiFetch from "@/lib/apiFetch";
import { import {
ActionIcon,
Avatar,
Badge,
Box,
Button, Button,
Card, Center,
Container, Divider,
Group, Group,
Pagination, Pagination,
Paper,
rem,
Skeleton, Skeleton,
Stack, Stack,
Text, Text,
ThemeIcon,
Title, Title,
Badge, Tooltip
ScrollArea,
Tooltip,
Divider,
} from "@mantine/core"; } from "@mantine/core";
import { useLocalStorage, useShallowEffect } from "@mantine/hooks"; import { useLocalStorage, useShallowEffect } from "@mantine/hooks";
import { showNotification } from "@mantine/notifications"; import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { import {
IconRefresh, IconActivity,
IconMessageCircle,
IconUser,
IconCalendar,
IconHash, IconHash,
IconCode, IconMessageCircle,
IconPhone,
IconRefresh,
IconRobot,
IconTrash,
IconUser
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import useSWR from "swr"; import useSWR from "swr";
dayjs.extend(relativeTime);
export default function WaHookHome() { export default function WaHookHome() {
const [page, setPage] = useLocalStorage({ key: "wa-hook-page", defaultValue: 1 }); const [page, setPage] = useLocalStorage({ key: "wa-hook-page", defaultValue: 1 });
const { data, error, isLoading, mutate } = useSWR( const { data, error, isLoading, mutate } = useSWR(
@@ -43,195 +53,202 @@ export default function WaHookHome() {
mutate(); mutate();
}, [page]); }, [page]);
async function handleReset() { const handleReset = () => {
await apiFetch["wa-hook"].reset.post(); modals.openConfirmModal({
mutate(); title: "Clear Activity Logs",
showNotification({ centered: true,
title: "Reset Completed", children: (
message: "All WhatsApp Hook data has been cleared.", <Text size="sm">
color: "teal", 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) if (error)
return ( return (
<Container p="xl"> <Center py={100}>
<Text c="red.5" ta="center" fz="lg" fw={500}> <Paper withBorder p="xl" radius="md">
Failed to load webhook data. <Text c="red" fw={600}>Failed to load activity data</Text>
</Text> <Button variant="light" color="red" mt="md" onClick={() => mutate()}>Retry</Button>
</Container> </Paper>
</Center>
); );
return ( return (
<Container <Stack gap="xl" py="sm">
size="lg" <Box>
p="xl" <Group justify="space-between" align="flex-end">
style={{ <Stack gap={4}>
background: "linear-gradient(145deg, #1a1a1a 0%, #111 100%)", <Group gap="xs">
borderRadius: 24, <ThemeIcon variant="light" color="teal" size="lg">
border: "1px solid rgba(0,255,200,0.15)", <IconActivity size={20} />
boxShadow: "0 0 30px rgba(0,255,200,0.1)", </ThemeIcon>
}} <Title order={2} fw={900}>
> Hook Activity Monitor
<Stack gap="xl"> </Title>
<Group justify="space-between" align="center"> </Group>
<Stack gap={2}> <Text c="dimmed" size="sm">
<Title order={2} c="#EAEAEA" fw={700} style={{ letterSpacing: 0.5 }}> Track real-time WhatsApp messages and AI flow responses.
WhatsApp Hook Monitor
</Title>
<Text c="#9A9A9A" fz="sm">
Real-time webhook activity and message tracking
</Text> </Text>
</Stack> </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 <Button
color="red"
variant="light"
leftSection={<IconTrash size={18} />}
onClick={handleReset} onClick={handleReset}
leftSection={<IconRefresh size={18} />}
variant="gradient"
gradient={{ from: "#00FFC8", to: "#00FFFF", deg: 45 }}
radius="xl"
size="md"
> >
Reset Data Clear Logs
</Button> </Button>
</Tooltip> </Group>
</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"> <Box
{data?.data?.list?.length ? ( p="md"
data.data.list.map((item) => { bg="var(--mantine-color-dark-8)"
const parsed = JSON.parse((item.data as any) || "{}"); style={{ borderRadius: rem(8), borderLeft: "4px solid var(--mantine-color-teal-6)" }}
>
return ( <Group gap="xs" mb={4}>
<Card <IconMessageCircle size={14} color="var(--mantine-color-teal-6)" />
key={item.id} <Text fz="xs" fw={700} c="teal" tt="uppercase">
radius="lg" Inbound Message
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"})
</Text> </Text>
</Group> </Group>
<Text fz="sm">{parsed.question || "(Empty message)"}</Text>
</Box>
{/* Pertanyaan / Pesan */} {parsed.answer && (
<Group gap="xs" align="center"> <Box
<IconMessageCircle size={16} color="#00FFFF" /> p="md"
<Text c="#9A9A9A" fz="sm"> bg="var(--mantine-color-teal-9)"
{parsed.question || "(No question)"} style={{
</Text> borderRadius: rem(8),
</Group> borderLeft: "4px solid var(--mantine-color-blue-6)",
marginLeft: rem(20)
{/* ID Record */} }}
<Group gap="xs" align="center"> >
<IconHash size={16} color="#00FFC8" /> <Group justify="space-between" mb={4}>
<Text c="#9A9A9A" fz="xs"> <Group gap="xs">
{item.id} <IconRobot size={16} color="var(--mantine-color-blue-4)" />
</Text> <Text fz="xs" fw={700} c="blue.4" tt="uppercase">
</Group> AI Response
</Text>
{/* Timestamp */} </Group>
<Group gap="xs" align="center"> {parsed.flowId && (
<IconCalendar size={16} color="#00FFFF" /> <Badge size="xs" color="blue" variant="light">
<Text c="#9A9A9A" fz="xs"> Flow: {parsed.flowId}
{dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss")} </Badge>
</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>
</Group> </Group>
)} <Text fz="sm">{parsed.answer}</Text>
</Box>
)}
{/* Jawaban */} <Group justify="space-between">
{parsed.answer && ( <Group gap="xs">
<Card <IconHash size={12} color="var(--mantine-color-dimmed)" />
p="sm" <Text fz="xs" c="dimmed" ff="monospace">
radius="md" ID: {item.id}
style={{ </Text>
backgroundColor: "rgba(45,45,45,0.7)", </Group>
border: "1px solid rgba(0,255,255,0.15)", </Group>
}} </Stack>
> </Paper>
<Stack gap={4}> );
<Text c="#EAEAEA" fw={500} fz="sm"> })
Bot Answer ) : (
</Text> <Center py={80}>
<Text c="#EAEAEA" fz="sm"> <Stack align="center" gap="xs">
{parsed.answer} <IconActivity size={48} color="var(--mantine-color-gray-4)" />
</Text> <Text fw={500} c="dimmed">
</Stack> No hook activity detected yet.
</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.
</Text> </Text>
</Card> </Stack>
)} </Center>
)}
</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> </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 ( return (
<Container size="xl" w={"100%"}> <Container size="xl" w={"100%"}>
<Group justify="flex-start" p={"md"}> <Group justify="flex-start" p={"md"}>
<Button {/* <Button
color="cyan" color="cyan"
size="xs" size="xs"
radius={"lg"} radius={"lg"}
@@ -16,7 +16,7 @@ export default function WaHookLayout() {
} }
> >
Flow WA Hook Flow WA Hook
</Button> </Button> */}
</Group> </Group>
<Outlet /> <Outlet />
</Container> </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() { 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 { Navigate, Outlet } from "react-router-dom";
import useSWR from "swr"; import useSWR from "swr";
import apiFetch from "@/lib/apiFetch"; 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 { useState } from "react";
import clientRoutes from "@/clientRoutes"; import clientRoutes from "@/clientRoutes";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import {
IconPlayerPlay,
IconRefresh,
IconScan,
IconCircleCheck,
IconAlertCircle,
IconSettings,
IconDeviceMobile,
} from "@tabler/icons-react";
export default function WajsLayout() { export default function WajsLayout() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -13,59 +35,145 @@ export default function WajsLayout() {
revalidateOnReconnect: false, revalidateOnReconnect: false,
revalidateIfStale: false, revalidateIfStale: false,
refreshInterval: 3000, refreshInterval: 3000,
onSuccess(data, key, config) {
console.log(data.data?.state);
},
}); });
if (!data?.data?.state) return <Outlet />; const state = data?.data?.state;
if (data.data?.state.qr)
if (!state) return <Outlet />;
if (state.qr) {
return <Navigate to={clientRoutes["/wajs/qrcode"]} replace />; 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 ( return (
<Stack> <Stack gap="lg">
<Group> <Paper withBorder p="md" radius="md" shadow="sm">
<Button <Group justify="space-between">
loading={loading && !data.data?.state.ready} <Group gap="md">
disabled={data.data?.state.ready} <Box
onClick={() => { p={8}
setLoading(true); bg="teal.0"
apiFetch.api.wa.start.post(); style={{ borderRadius: "8px", display: "flex", alignItems: "center" }}
}} >
> <IconDeviceMobile size={24} color="var(--mantine-color-teal-6)" />
{data.data?.state.ready ? "Ready" : "Start"} </Box>
</Button> <Box>
<Button <Title order={4}>WhatsApp Connection</Title>
onClick={() => { <Group gap={6}>
setLoading(true); {state.ready ? (
apiFetch.api.wa.restart.post(); <Badge
}} color="green"
> variant="light"
Reconnect leftSection={<IconCircleCheck size={12} />}
</Button> >
<Button Connected & Ready
color="red" </Badge>
onClick={() => { ) : state.isStarting ? (
setLoading(true); <Badge
modals.openConfirmModal({ color="yellow"
title: "Rescan QR", variant="light"
children: <Text>Are you sure you want to rescan QR?</Text>, leftSection={<Loader size={10} color="yellow" />}
confirmProps: { color: "red" }, >
labels: { Connecting...
cancel: "Cancel", </Badge>
confirm: "Rescan QR", ) : (
}, <Badge
onCancel: () => setLoading(false), color="red"
onConfirm: () => { variant="light"
apiFetch.api.wa.restart.post(); leftSection={<IconAlertCircle size={12} />}
setLoading(false); >
}, Disconnected
}); </Badge>
}} )}
> </Group>
Rescan QR </Box>
</Button> </Group>
</Group>
<Outlet /> <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> </Stack>
); );
} }

View File

@@ -1,310 +1,201 @@
import { useState, useMemo } from "react"; import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
import { import {
Button, Button,
Card,
Checkbox, Checkbox,
Divider,
Group, Group,
Select,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Select,
Divider,
Title, Title,
Paper,
Box,
SimpleGrid,
rem,
ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IconCode, IconCheck, IconX } from "@tabler/icons-react"; import {
import Editor from "@monaco-editor/react"; IconCheck,
import apiFetch from "@/lib/apiFetch"; IconX,
IconArrowLeft,
IconLink,
IconKey,
IconWebhook,
} from "@tabler/icons-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom"; 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() { export default function WebhookCreate() {
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(""); const [loading, setLoading] = useState(false);
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 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 { try {
return JSON.stringify(JSON.parse(value || "{}"), null, 2); const { data } = await apiFetch.api.webhook.create.post(values);
} catch {
return value || "{}"; 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 ( return (
<Stack style={{ backgroundColor: "#191919" }} p="xl"> <Stack gap="xl" py="sm">
<Stack <Box>
gap="md" <Group justify="space-between" align="flex-end">
w={"100%"} <Stack gap={4}>
mx="auto" <Group gap="xs">
bg="rgba(45,45,45,0.6)" <Button
p="xl" variant="subtle"
style={{ color="gray"
borderRadius: "20px", size="xs"
backdropFilter: "blur(12px)", leftSection={<IconArrowLeft size={14} />}
border: "1px solid rgba(0,255,200,0.2)", onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
// boxShadow: "0 0 25px rgba(0,255,200,0.15)", p={0}
}} >
> Back to List
<Group justify="space-between"> </Button>
<Title order={2} c="#EAEAEA" fw={600}> </Group>
Create Webhook <Group gap="xs">
</Title> <ThemeIcon variant="light" color="teal" size="lg">
<IconCode color="#00FFFF" size={28} /> <IconWebhook size={20} />
</Group> </ThemeIcon>
<Title order={2} fw={900}>
<Divider color="rgba(0,255,200,0.2)" /> Create Webhook
</Title>
<TextInput </Group>
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> </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> </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> </Stack>
); );
} }

View File

@@ -9,330 +9,273 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title,
Paper,
ActionIcon,
Tooltip,
Container,
Box,
Loader,
Center,
SimpleGrid,
rem,
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useForm } from "@mantine/form";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications"; 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 type { WebHook } from "generated/prisma";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import useSWR from "swr"; import useSWR from "swr";
export default function WebhookEdit() { export default function WebhookEdit() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const id = searchParams.get("id"); const id = searchParams.get("id");
const navigate = useNavigate();
const { data, error, isLoading, mutate } = useSWR( const { data, error, isLoading, mutate } = useSWR(
"/", id ? `/webhook/${id}` : null,
() => () => apiFetch.api.webhook.find({ id: id! }).get(),
apiFetch.api.webhook { dedupingInterval: 3000 }
.find({
id: id!,
})
.get(),
{ dedupingInterval: 3000 },
); );
const navigate = useNavigate();
useShallowEffect(() => { const handleDelete = () => {
mutate(); modals.openConfirmModal({
}, [data]); 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 (isLoading)
if (error) return <div>Error: {error}</div>; return (
if (!data?.data?.webhook) return <div>No data</div>; <Center py={100}>
<Loader color="teal" size="lg" type="dots" />
</Center>
);
return ( if (error || !data?.data?.webhook)
<Stack> return (
<Group justify="space-between"> <Center py={100}>
<Title order={2}>Edit Webhook</Title> <Paper withBorder p="xl" radius="md">
<Button <Stack align="center">
variant="outline" <IconX size={48} color="red" />
onClick={() => { <Text fw={600}>Webhook not found or error loading data</Text>
modals.openConfirmModal({ <Button variant="light" onClick={() => navigate(-1)}>
title: "Remove Webhook", Go Back
children: ( </Button>
<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,
}}
/>
</Stack> </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 <Button
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])} variant="light"
variant="subtle" color="red"
c="#EAEAEA" leftSection={<IconTrash size={18} />}
styles={{ onClick={handleDelete}
root: { backgroundColor: "#2D2D2D", borderColor: "#00FFC8" },
}}
> >
Cancel Remove Webhook
</Button>
<Button
onClick={onSubmit}
style={{
background: "linear-gradient(90deg, #00FFC8, #00FFFF)",
color: "#191919",
}}
>
Save Webhook
</Button> </Button>
</Group> </Group>
</Stack> <Divider mt="md" variant="dotted" />
</Box>
<EditView webhook={data.data.webhook} onUpdated={() => mutate()} />
</Stack> </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, Stack,
Divider, Divider,
Button, Button,
Box,
SimpleGrid,
Paper,
rem,
ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconLink, IconLink,
@@ -23,6 +28,9 @@ import {
IconEdit, IconEdit,
IconPlus, IconPlus,
IconMessageReply, IconMessageReply,
IconWebhook,
IconWorld,
IconExternalLink,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useSWR from "swr"; import useSWR from "swr";
@@ -34,7 +42,7 @@ import { useShallowEffect } from "@mantine/hooks";
export default function WebhookHome() { export default function WebhookHome() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, error, isLoading, mutate } = useSWR( const { data, error, isLoading, mutate } = useSWR(
"/", "/webhook-list",
apiFetch.api.webhook.list.get, apiFetch.api.webhook.list.get,
{ dedupingInterval: 3000, refreshInterval: 3000 }, { dedupingInterval: 3000, refreshInterval: 3000 },
); );
@@ -45,216 +53,209 @@ export default function WebhookHome() {
mutate(); mutate();
}, []); }, []);
function ButtonCreate() { const handleRefresh = () => {
return ( mutate();
<Tooltip label="Create new webhook" withArrow color="teal"> notifications.show({
<Button title: "Refreshing Data",
radius="xl" message: "Webhook list has been updated.",
size="md" color: "teal",
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>
);
}
if (isLoading) if (isLoading)
return ( return (
<Center h="100vh" bg="#191919"> <Center py={100}>
<Loader color="teal" size="lg" /> <Stack align="center" gap="md">
<Loader color="teal" size="lg" type="dots" />
<Text c="dimmed" fz="sm">Loading your webhooks...</Text>
</Stack>
</Center> </Center>
); );
if (error) if (error)
return ( return (
<Center h="100vh" bg="#191919"> <Center py={100}>
<Text c="#FF4B4B" fw={500}> <Paper withBorder p="xl" radius="md" bg="var(--mantine-color-dark-8)">
Failed to load webhooks. Please try again. <Stack align="center" gap="sm">
</Text> <IconX size={48} color="var(--mantine-color-red-6)" />
</Center> <Text fw={600}>Failed to load webhooks</Text>
); <Button variant="light" color="red" onClick={() => mutate()}>
Try Again
if (!webhooks.length) </Button>
return ( </Stack>
<Center h="100vh" bg="#191919"> </Paper>
<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> </Center>
); );
return ( return (
<Stack style={{ backgroundColor: "#191919" }} p="xl"> <Stack gap="xl" py="sm">
<Title order={2} c="#EAEAEA" fw={600}> <Box>
Webhook Manager <Group justify="space-between" align="flex-end">
</Title> <Stack gap={4}>
<Group justify="end" mb="lg"> <Group gap="xs">
<IconWebhook size={32} color="var(--mantine-color-teal-filled)" />
<ButtonCreate /> <Title order={2} fw={900}>
<Tooltip label="Refresh webhooks" withArrow color="cyan"> Webhook Manager
<ActionIcon </Title>
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>
</Group> </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"}> {!webhooks.length ? (
<Group> <Center py={80}>
<Badge <Stack align="center" gap="xl">
color={webhook.enabled ? "teal" : "red"} <Box style={{ textAlign: "center" }}>
radius="xl" <ThemeIcon size={80} radius="xl" color="gray" variant="light" mb="md">
leftSection={ <IconWorld size={40} />
webhook.enabled ? ( </ThemeIcon>
<IconCheck size={14} /> <Title order={3}>No Webhooks Configured</Title>
) : ( <Text c="dimmed" mt="xs">
<IconX size={14} /> Start by adding your first endpoint to receive WhatsApp events.
)
}
>
{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}
</Text> </Text>
</Stack> </Box>
<Divider color="rgba(0,255,200,0.2)" my="sm" /> <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"> <Tooltip label="Edit Settings" withArrow>
<Group gap="xs"> <ActionIcon
<IconCode size={16} color="#00FFC8" /> variant="subtle"
<Text c="#9A9A9A" size="sm"> color="gray"
Method: size="lg"
</Text> onClick={() =>
<Text c="#EAEAEA" size="sm" fw={500}> navigate(
{webhook.method} `${clientRoutes["/sq/dashboard/webhook/webhook-edit"]}?id=${webhook.id}`,
</Text> )
}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
</Group> </Group>
<Group gap="xs"> <Text c="dimmed" fz="sm" lineClamp={2} mb="xl" h={rem(40)}>
<IconLink size={16} color="#00FFC8" /> {webhook.description || "No description provided for this webhook."}
<Text c="#9A9A9A" size="sm"> </Text>
URL:
</Text>
<Text c="#EAEAEA" size="sm" fw={500}>
{webhook.url}
</Text>
</Group>
<Group gap="xs"> <Divider mb="xl" variant="dashed" />
<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>
{/* <Group gap="xs"> <Stack gap="sm">
<Text c="#9A9A9A" size="sm"> <DetailRow
Headers: icon={IconCode}
</Text> label="Method"
<Text c="#EAEAEA" size="sm" fw={500}> value={webhook.method}
{Object.keys(webhook.headers || {}).length color="blue"
? webhook.headers />
: "No headers configured"} <DetailRow
</Text> icon={IconLink}
</Group> */} label="Endpoint"
value={webhook.url}
{/* <Group gap="xs"> color="teal"
<Text c="#9A9A9A" size="sm"> isLink
Payload: />
</Text> <DetailRow
<Text c="#EAEAEA" size="sm" fw={500}> icon={IconKey}
{Object.keys(webhook.payload || {}).length label="Token"
? webhook.payload value={webhook.apiToken ? `${webhook.apiToken.slice(0, 12)}...` : "None"}
: "Empty payload"} color="violet"
</Text> />
</Group> */} </Stack>
</Stack> </Card>
</Card> ))}
))} </SimpleGrid>
</Stack> )}
</Stack> </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 { import { Outlet } from "react-router-dom";
Button,
Group,
Stack,
Title,
Tooltip,
Divider,
Container,
Paper,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useNavigate, Outlet } from "react-router-dom";
export default function WebhookLayout() { export default function WebhookLayout() {
const navigate = useNavigate();
return <Outlet />; return <Outlet />;
} }

View File

@@ -1,22 +1,140 @@
import apiFetch from "@/lib/apiFetch"; import apiFetch from "@/lib/apiFetch";
import { ReactQRCode } from "@lglab/react-qr-code"; 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 useSWR from "swr";
import { useNavigate, Navigate } from "react-router-dom";
import clientRoutes from "@/clientRoutes";
export default function QrcodePage() { 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, revalidateOnFocus: false,
revalidateOnReconnect: false, revalidateOnReconnect: false,
revalidateIfStale: false, revalidateIfStale: false,
refreshInterval: 3000, 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 ( return (
<Container size={"sm"}> <Box
<h1>QrCode</h1> style={{
<Group> minHeight: "100vh",
<Card bg={"white"}> display: "flex",
<ReactQRCode size={256} value={data?.data?.qr || ""} /> alignItems: "center",
</Card> justifyContent: "center",
</Group> background: "var(--mantine-color-dark-9)",
</Container> }}
>
<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 = type HookData =
| { eventType: "qr"; qr: string } | { eventType: "qr"; qr: string }
| { eventType: "start" } | { eventType: "start" }
| { eventType: "ready" } | { eventType: "ready" }
| { eventType: "disconnected"; reason?: string } | { eventType: "disconnected"; reason?: string }
| { eventType: "reconnect" } | { eventType: "reconnect" }
| { eventType: "auth_failure"; msg: string } | { eventType: "auth_failure"; msg: string }
| { eventType: "message" } & Partial<WAWebJS.Message>; | { eventType: "message" } & Partial<WAWebJS.Message>;
async function handleHook(data: HookData) { async function handleHook(data: HookData) {
const webHooks = await prisma.webHook.findMany({ where: { enabled: true } }); const webHooks = await prisma.webHook.findMany({ where: { enabled: true } });
if (webHooks.length === 0) return; if (webHooks.length === 0) return;
await Promise.allSettled( await Promise.allSettled(
webHooks.map(async (hook) => { webHooks.map(async (hook) => {
try { try {
log(`🌐 Mengirim webhook ke ${hook.name} ${hook.url}`); log(`🌐 Mengirim webhook ke ${hook.name} ${hook.url}`);
let res: Response = {} as Response; let res: Response = {} as Response;
res = await fetch(hook.url, { res = await fetch(hook.url, {
method: hook.method, method: hook.method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${hook.apiToken}`, Authorization: `Bearer ${hook.apiToken}`,
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
const json = await res.text(); const json = await res.text();
logger.info(`[RESPONSE] ${hook.name} ${hook.url}: ${json}`); logger.info(`[RESPONSE] ${hook.name} ${hook.url}: ${json}`);
} catch (err) { } catch (err) {
logger.error(`[ERROR] ${hook.name} ${hook.url}:`); logger.error(`[ERROR] ${hook.name} ${hook.url}:`);
logger.error(`[ERROR] ${hook.name}: ${err}`); logger.error(`[ERROR] ${hook.name}: ${err}`);
} }
}) })
) )
} }
// === STATE GLOBAL === // === STATE GLOBAL ===
const state = { const state = {
client: null as Client | null, client: null as Client | null,
reconnectTimeout: null as NodeJS.Timeout | null, reconnectTimeout: null as NodeJS.Timeout | null,
isReconnecting: false, isReconnecting: false,
isStarting: false, isStarting: false,
qr: null as string | null, qr: null as string | null,
ready: false, ready: false,
async restart() { async restart() {
log('🔄 Restart manual diminta...'); log('🔄 Restart manual diminta...');
await destroyClient(); await destroyClient();
await startClient(); await startClient();
}, },
async forceStart() { async forceStart() {
log('⚠️ Force start — menghapus cache dan session auth...'); log('⚠️ Force start — menghapus cache dan session auth...');
await destroyClient(); await destroyClient();
await safeRm("./.wwebjs_auth"); await safeRm("./.wwebjs_auth");
await safeRm("./wwebjs_cache"); await safeRm("./wwebjs_cache");
await startClient(); await startClient();
}, },
async stop() { async stop() {
log('🛑 Stop manual diminta...'); log('🛑 Stop manual diminta...');
await destroyClient(); await destroyClient();
}, },
}; };
// === UTIL === // === UTIL ===
function log(...args: any[]) { function log(...args: any[]) {
console.log(`[${new Date().toISOString()}]`, ...args); console.log(`[${new Date().toISOString()}]`, ...args);
} }
async function safeRm(path: string) { async function safeRm(path: string) {
try { try {
await fs.rm(path, { recursive: true, force: true }); await fs.rm(path, { recursive: true, force: true });
} catch (err) { } catch (err) {
log(`⚠️ Gagal hapus ${path}:`, err); log(`⚠️ Gagal hapus ${path}:`, err);
} }
} }
// === CLEANUP CLIENT === // === CLEANUP CLIENT ===
async function destroyClient() { async function destroyClient() {
if (state.reconnectTimeout) { if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout); clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null; state.reconnectTimeout = null;
} }
if (state.client) { if (state.client) {
try { try {
state.client.removeAllListeners(); state.client.removeAllListeners();
await state.client.destroy(); await state.client.destroy();
log('🧹 Client lama dihentikan & listener dibersihkan'); log('🧹 Client lama dihentikan & listener dibersihkan');
} catch (err) { } catch (err) {
log('⚠️ Gagal destroy client:', err); log('⚠️ Gagal destroy client:', err);
}
state.client = null;
state.ready = false;
} }
state.client = null;
state.ready = false;
}
} }
let connectedAt: number | null = null; let connectedAt: number | null = null;
// === PEMBUATAN CLIENT === // === PEMBUATAN CLIENT ===
async function startClient() { async function startClient() {
if (state.isStarting || state.isReconnecting) { if (state.isStarting || state.isReconnecting) {
log('⏳ startClient diabaikan — proses sedang berjalan...'); log('⏳ startClient diabaikan — proses sedang berjalan...');
return; 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...'); // === EVENT LISTENERS ===
handleHook({ eventType: "start" }); 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({ client.on('ready', () => {
authStrategy: new LocalAuth({ connectedAt = Date.now();
dataPath: process.env.WWEBJS_AUTH || path.join(process.cwd(), '.wwebjs_auth') log('✅ WhatsApp client siap digunakan!');
}), state.ready = true;
puppeteer: { state.isReconnecting = false;
headless: true, state.isStarting = false;
args: [ state.qr = null;
'--no-sandbox', handleHook({ eventType: "ready" });
'--disable-setuid-sandbox', if (state.reconnectTimeout) {
'--disable-dev-shm-usage', clearTimeout(state.reconnectTimeout);
'--disable-gpu', state.reconnectTimeout = null;
],
},
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('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 === // === HANDLER PESAN MASUK ===
async function handleIncomingMessage(msg: WAWebJS.Message) { async function handleIncomingMessage(msg: WAWebJS.Message) {
const chat = await msg.getChat(); const chat = await msg.getChat();
// await chat.sendStateTyping(); // await chat.sendStateTyping();
log(`💬 Pesan dari ${msg.from}: ${msg.body || '[MEDIA]'}`); log(`💬 Pesan dari ${msg.from}: ${msg.body || '[MEDIA]'}`);
if (!connectedAt) return; if (!connectedAt) return;
if (msg.timestamp * 1000 < connectedAt) return; if (msg.timestamp * 1000 < connectedAt) return;
if (msg.from.endsWith('@g.us') || msg.isStatus || msg.from === 'status@broadcast') { if (msg.from.endsWith('@g.us') || msg.isStatus || msg.from === 'status@broadcast') {
log(`🚫 Pesan dari grup/status diabaikan (${msg.from})`); log(`🚫 Pesan dari grup/status diabaikan (${msg.from})`);
return; return;
} }
if (msg.hasMedia) { if (msg.hasMedia) {
const media = await msg.downloadMedia(); const media = await msg.downloadMedia();
(msg as any).media = media; (msg as any).media = media;
} }
handleHook({ eventType: "message", ...msg }) handleHook({ eventType: "message", ...msg })
} }
// === CLEANUP SAAT EXIT === // === CLEANUP SAAT EXIT ===
process.on('SIGINT', () => { process.on('SIGINT', () => {
log('🛑 SIGINT diterima, menutup client...'); log('🛑 SIGINT diterima, menutup client...');
destroyClient().then(() => { destroyClient().then(() => {
process.exit(0); process.exit(0);
}).catch((err) => { }).catch((err) => {
log('⚠️ Error saat destroyClient:', err); log('⚠️ Error saat destroyClient:', err);
process.exit(1); process.exit(1);
}); });
}); });
@@ -249,5 +249,5 @@ const getState = () => state;
export { destroyClient, getState, startClient }; export { destroyClient, getState, startClient };
if (import.meta.main) { if (import.meta.main) {
await startClient(); await startClient();
} }

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 {}