feat: complete lid format handling and UI updates
This commit is contained in:
35
.agents/skills/whatsapp-web/.claude-plugin/plugin.json
Normal file
35
.agents/skills/whatsapp-web/.claude-plugin/plugin.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
142
.agents/skills/whatsapp-web/SKILL.md
Normal file
142
.agents/skills/whatsapp-web/SKILL.md
Normal 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
1
.qwen/skills/whatsapp-web
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/whatsapp-web
|
||||||
30
bun.lock
30
bun.lock
@@ -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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
39
x.ts
Normal 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
218
x.tsx
@@ -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 {}
|
|
||||||
Reference in New Issue
Block a user