Compare commits
3 Commits
049bddeba1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0152229b96 | ||
|
|
32610ebfd1 | ||
|
|
98b134e72a |
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
|
||||
92
GEMINI.md
Normal file
92
GEMINI.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# GEMINI.md - Project Context & Instructions
|
||||
|
||||
## Project Overview
|
||||
**wajs-server** is a full-stack WhatsApp integration platform built with Bun, ElysiaJS, and React. It provides a robust API and a web-based dashboard to manage WhatsApp sessions, send/receive messages, and integrate with external systems via webhooks.
|
||||
|
||||
### Main Technologies
|
||||
- **Runtime**: [Bun](https://bun.sh/)
|
||||
- **Backend Framework**: [ElysiaJS](https://elysiajs.com/)
|
||||
- **Frontend Library**: [React](https://react.dev/) with [Mantine UI](https://mantine.dev/)
|
||||
- **Database ORM**: [Prisma](https://www.prisma.io/) (Targeting PostgreSQL)
|
||||
- **WhatsApp Integration**: [whatsapp-web.js](https://github.com/pedroslopez/whatsapp-web.js)
|
||||
- **State Management/Data Fetching**: [SWR](https://swr.vercel.app/)
|
||||
- **Routing**: [React Router](https://reactrouter.com/)
|
||||
|
||||
### Architecture
|
||||
- **Server Entry Point**: `src/index.tsx` - Orchestrates the ElysiaJS server and serves the React frontend.
|
||||
- **WhatsApp Service**: `src/server/lib/wa/wa_service.ts` - A singleton service managing the WhatsApp client lifecycle, event handling, and webhook dispatching.
|
||||
- **API Routes**: Located in `src/server/routes/`, including:
|
||||
- `wa_route.ts`: Core WhatsApp operations (send message, QR status, etc.).
|
||||
- `webhook_route.ts`: CRUD for external webhooks.
|
||||
- `auth_route.ts` & `apikey_route.ts`: Authentication and API key management.
|
||||
- **Frontend**: Located in `src/pages/`, with routes defined in `src/AppRoutes.tsx`. It uses a dashboard layout (`src/pages/sq/dashboard/`).
|
||||
- **Database Schema**: `prisma/schema.prisma` defines models for `User`, `ApiKey`, `WebHook`, `WaHook`, and `ChatFlows`.
|
||||
|
||||
---
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Prerequisites
|
||||
- [Bun](https://bun.sh/) installed.
|
||||
- A PostgreSQL database instance.
|
||||
|
||||
### Setup
|
||||
1. **Install dependencies**:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
2. **Environment Configuration**:
|
||||
Copy `.env.example` to `.env` and fill in the required variables:
|
||||
- `DATABASE_URL`: PostgreSQL connection string.
|
||||
- `JWT_SECRET`: Secret for JWT signing.
|
||||
- `PORT`: Server port (default: 3000).
|
||||
|
||||
3. **Database Migration**:
|
||||
```bash
|
||||
bunx prisma migrate dev
|
||||
```
|
||||
|
||||
### Development
|
||||
Start the development server with hot-reloading:
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Production
|
||||
1. **Build the frontend**:
|
||||
```bash
|
||||
bun build
|
||||
```
|
||||
2. **Start the server**:
|
||||
```bash
|
||||
bun start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Coding Style
|
||||
- **TypeScript**: The project is strictly typed. Ensure new features are properly typed.
|
||||
- **Functional Components**: React frontend uses functional components and hooks.
|
||||
- **Elysia Patterns**: Use Elysia's `use()` plugin system for modular routes and middlewares.
|
||||
|
||||
### Key Workflows
|
||||
- **WhatsApp Lifecycle**: The client uses `LocalAuth` for session persistence (stored in `.wwebjs_auth/`). In production, the client starts automatically on server boot.
|
||||
- **Webhooks**: WhatsApp events are broadcast to all enabled webhooks defined in the database.
|
||||
- **API Security**: Protected routes use the `apiAuth` middleware, which checks for either a valid JWT or a registered API key.
|
||||
|
||||
### Directory Structure
|
||||
- `src/server/`: Backend logic.
|
||||
- `src/pages/`: Frontend views.
|
||||
- `src/components/`: Reusable React components.
|
||||
- `prisma/`: Database configuration and migrations.
|
||||
- `generated/prisma/`: Auto-generated Prisma client (output directory is customized in `schema.prisma`).
|
||||
|
||||
---
|
||||
|
||||
## Instructional Context for AI
|
||||
- When modifying the WhatsApp logic, refer to `src/server/lib/wa/wa_service.ts`.
|
||||
- When adding new API endpoints, register them in `src/index.tsx`.
|
||||
- Frontend routing is managed in `src/AppRoutes.tsx`; follow the nested structure under `/sq/dashboard/` for new dashboard pages.
|
||||
- Use `prisma` from `src/server/lib/prisma.ts` for database interactions.
|
||||
49
__tests__/seed.test.ts
Normal file
49
__tests__/seed.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { mock } from "bun:test";
|
||||
|
||||
const mockPrisma = {
|
||||
user: {
|
||||
upsert: mock(async () => ({ id: "user-1", email: "admin@example.com" })),
|
||||
},
|
||||
apiKey: {
|
||||
upsert: mock(async () => ({ id: "key-1" })),
|
||||
},
|
||||
webHook: {
|
||||
findFirst: mock(async () => null as any),
|
||||
create: mock(async () => ({ id: "webhook-1" })),
|
||||
},
|
||||
chatFlows: {
|
||||
upsert: mock(async () => ({ id: "flow-1" })),
|
||||
},
|
||||
$disconnect: mock(async () => {}),
|
||||
};
|
||||
|
||||
mock.module("@/server/lib/prisma", () => ({
|
||||
prisma: mockPrisma,
|
||||
}));
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { seed } from "../prisma/seed";
|
||||
|
||||
describe("Prisma Seed", () => {
|
||||
test("seed function should populate default data", async () => {
|
||||
const result = await seed();
|
||||
|
||||
expect(result.user.email).toBe("admin@example.com");
|
||||
expect(mockPrisma.user.upsert).toHaveBeenCalled();
|
||||
expect(mockPrisma.apiKey.upsert).toHaveBeenCalled();
|
||||
expect(mockPrisma.webHook.findFirst).toHaveBeenCalled();
|
||||
expect(mockPrisma.webHook.create).toHaveBeenCalled();
|
||||
expect(mockPrisma.chatFlows.upsert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("seed function should skip webhook creation if it already exists", async () => {
|
||||
// Reset mocks
|
||||
mockPrisma.webHook.create.mockClear();
|
||||
mockPrisma.webHook.findFirst.mockResolvedValueOnce({ id: "existing-webhook" });
|
||||
|
||||
await seed();
|
||||
|
||||
expect(mockPrisma.webHook.findFirst).toHaveBeenCalled();
|
||||
expect(mockPrisma.webHook.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
30
bun.lock
30
bun.lock
@@ -11,6 +11,8 @@
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@lglab/react-qr-code": "^1.4.9",
|
||||
"@mantine/core": "^8.3.13",
|
||||
"@mantine/dates": "^8.3.14",
|
||||
"@mantine/form": "^8.3.14",
|
||||
"@mantine/hooks": "^8.3.13",
|
||||
"@mantine/modals": "^8.3.13",
|
||||
"@mantine/notifications": "^8.3.13",
|
||||
@@ -49,7 +51,7 @@
|
||||
"uuid": "^13.0.0",
|
||||
"whatsapp-api-js": "^6.2.1",
|
||||
"whatsapp-client-sdk": "^1.6.0",
|
||||
"whatsapp-web.js": "github:pedroslopez/whatsapp-web.js#main",
|
||||
"whatsapp-web.js": "^1.34.6",
|
||||
"yaml": "^2.8.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -65,7 +67,7 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
@@ -151,6 +153,10 @@
|
||||
|
||||
"@mantine/core": ["@mantine/core@8.3.13", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.13", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-ZgW4vqN4meaPyIMxzAufBvsgmJRfYZdTpsrAOcS8pWy7m9e8i685E7XsAxnwJCOIHudpvpvt+7Bx7VaIjEsYEw=="],
|
||||
|
||||
"@mantine/dates": ["@mantine/dates@8.3.14", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "8.3.14", "@mantine/hooks": "8.3.14", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-NdStRo2ZQ55MoMF5B9vjhpBpHRDHF1XA9Dkb1kKSdNuLlaFXKlvoaZxj/3LfNPpn7Nqlns78nWt4X8/cgC2YIg=="],
|
||||
|
||||
"@mantine/form": ["@mantine/form@8.3.14", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "klona": "^2.0.6" }, "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-LJUeab+oF+YzATrm/K03Z/QoVVYlaolWqLUZZj7XexNA4hS2/ycKyWT07YhGkdHTLXkf3DUtrg1sS77K7Oje8A=="],
|
||||
|
||||
"@mantine/hooks": ["@mantine/hooks@8.3.13", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-7YMbMW/tR9E8m/9DbBW01+3RNApm2mE/JbRKXf9s9+KxgbjQvq0FYGWV8Y4+Sjz48AO4vtWk2qBriUTgBMKAyg=="],
|
||||
|
||||
"@mantine/modals": ["@mantine/modals@8.3.13", "", { "peerDependencies": { "@mantine/core": "8.3.13", "@mantine/hooks": "8.3.13", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-5jIRJKEupQerHfPGcPHgQk+J6dGBO7spC66VgEZqCNRpbWhowCxBNGEW5LN1hZE9sLYBJg+z2MazPws4A1GohQ=="],
|
||||
@@ -181,7 +187,7 @@
|
||||
|
||||
"@prisma/get-platform": ["@prisma/get-platform@6.19.2", "", { "dependencies": { "@prisma/debug": "6.19.2" } }, "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.11.1", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-YmhAxs7XPuxN0j7LJloHpfD1ylhDuFmmwMvfy/+6nBSrETT2ycL53LrhgPtR+f+GcPSybQVuQ5inWWu5MrWCpA=="],
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.12.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw=="],
|
||||
|
||||
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="],
|
||||
|
||||
@@ -279,7 +285,7 @@
|
||||
|
||||
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
|
||||
|
||||
"bare-fs": ["bare-fs@4.5.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw=="],
|
||||
"bare-fs": ["bare-fs@4.5.3", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ=="],
|
||||
|
||||
"bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="],
|
||||
|
||||
@@ -325,7 +331,7 @@
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chromium-bidi": ["chromium-bidi@12.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg=="],
|
||||
"chromium-bidi": ["chromium-bidi@13.1.0", "", { "dependencies": { "mitt": "^3.0.1", "puppeteer": "^24.36.0", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-IdGNojX6S04+wgJOALzvkkIyLelhEGqI8xSctwiYJJGSi9T2eBjwAQW2UjBD/mCXv/rUkNlH2+h7jz+58vT74A=="],
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
@@ -391,7 +397,7 @@
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1534754", "", {}, "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ=="],
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1566079", "", {}, "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
@@ -449,6 +455,8 @@
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
@@ -549,6 +557,8 @@
|
||||
|
||||
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
|
||||
|
||||
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
|
||||
|
||||
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
@@ -685,9 +695,9 @@
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
|
||||
"puppeteer": ["puppeteer@24.35.0", "", { "dependencies": { "@puppeteer/browsers": "2.11.1", "chromium-bidi": "12.0.1", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1534754", "puppeteer-core": "24.35.0", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-sbjB5JnJ+3nwgSdRM/bqkFXqLxRz/vsz0GRIeTlCk+j+fGpqaF2dId9Qp25rXz9zfhqnN9s0krek1M/C2GDKtA=="],
|
||||
"puppeteer": ["puppeteer@24.37.0", "", { "dependencies": { "@puppeteer/browsers": "2.12.0", "chromium-bidi": "13.1.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1566079", "puppeteer-core": "24.37.0", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-s1jHugVhPtQjiJE6wUyonj4VEGWF+mfRDASqPMPsXgKcjZX0GaznBmcT9nLQ7bBL90phuQUqO4jiV5vTecZg4g=="],
|
||||
|
||||
"puppeteer-core": ["puppeteer-core@24.35.0", "", { "dependencies": { "@puppeteer/browsers": "2.11.1", "chromium-bidi": "12.0.1", "debug": "^4.4.3", "devtools-protocol": "0.0.1534754", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.3.10", "ws": "^8.19.0" } }, "sha512-vt1zc2ME0kHBn7ZDOqLvgvrYD5bqNv5y2ZNXzYnCv8DEtZGw/zKhljlrGuImxptZ4rq+QI9dFGrUIYqG4/IQzA=="],
|
||||
"puppeteer-core": ["puppeteer-core@24.37.0", "", { "dependencies": { "@puppeteer/browsers": "2.12.0", "chromium-bidi": "13.1.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.4.0", "ws": "^8.19.0" } }, "sha512-WoCBK36cBlbaxwuvPWhOp2+lR6O6ynHdDuvD8rEIkxPOPpUoMXSJuyiOWhHtexJBCLaMCAJk33QdYambvQl+og=="],
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
||||
@@ -841,7 +851,7 @@
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.3.10", "", {}, "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw=="],
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.0", "", {}, "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
@@ -849,7 +859,7 @@
|
||||
|
||||
"whatsapp-client-sdk": ["whatsapp-client-sdk@1.6.0", "", { "dependencies": { "axios": "^1.6.0", "form-data": "^4.0.0", "uuid": "^9.0.0" }, "peerDependencies": { "@supabase/supabase-js": "^2.0.0" }, "optionalPeers": ["@supabase/supabase-js"] }, "sha512-iAbdpv8tmw3RLPUxmw2ha8qbkKCuijbgl/HiDt4xpHgw3IWL3bB5o1SYX3qwuuEWwalXSNDiOu6W7lARJ8+XJw=="],
|
||||
|
||||
"whatsapp-web.js": ["whatsapp-web.js@github:pedroslopez/whatsapp-web.js#dd9df40", { "dependencies": { "@pedroslopez/moduleraid": "^5.0.2", "fluent-ffmpeg": "2.1.3", "mime": "^3.0.0", "node-fetch": "^2.6.9", "node-webpmux": "3.1.7", "puppeteer": "^24.31.0" }, "optionalDependencies": { "archiver": "^5.3.1", "fs-extra": "^10.1.0", "unzipper": "^0.10.11" } }, "pedroslopez-whatsapp-web.js-dd9df40"],
|
||||
"whatsapp-web.js": ["whatsapp-web.js@1.34.6", "", { "dependencies": { "@pedroslopez/moduleraid": "^5.0.2", "fluent-ffmpeg": "2.1.3", "mime": "^3.0.0", "node-fetch": "^2.6.9", "node-webpmux": "3.1.7", "puppeteer": "^24.31.0" }, "optionalDependencies": { "archiver": "^5.3.1", "fs-extra": "^10.1.0", "unzipper": "^0.10.11" } }, "sha512-+zgLBqARcVfuCG7b80c7Gkt+4Yh8w+oDWx7lL2gTA6nlaykHBne7NwJ5yGe2r7O9IYraIzs6HiCzNGKfu9AUBg=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ const config = {
|
||||
"value": "prisma-client-js"
|
||||
},
|
||||
"output": {
|
||||
"value": "/Users/bip/Documents/projects/jenna/wajs-server/generated/prisma",
|
||||
"value": "/Users/bip/Documents/projects/projects_2026/wajs-server/generated/prisma",
|
||||
"fromEnvVar": null
|
||||
},
|
||||
"config": {
|
||||
@@ -210,7 +210,7 @@ const config = {
|
||||
}
|
||||
],
|
||||
"previewFeatures": [],
|
||||
"sourceFilePath": "/Users/bip/Documents/projects/jenna/wajs-server/prisma/schema.prisma",
|
||||
"sourceFilePath": "/Users/bip/Documents/projects/projects_2026/wajs-server/prisma/schema.prisma",
|
||||
"isCustomOutput": true
|
||||
},
|
||||
"relativeEnvPaths": {
|
||||
@@ -224,7 +224,6 @@ const config = {
|
||||
"db"
|
||||
],
|
||||
"activeProvider": "postgresql",
|
||||
"postinstall": true,
|
||||
"inlineDatasources": {
|
||||
"db": {
|
||||
"url": {
|
||||
|
||||
@@ -197,7 +197,7 @@ const config = {
|
||||
"value": "prisma-client-js"
|
||||
},
|
||||
"output": {
|
||||
"value": "/Users/bip/Documents/projects/jenna/wajs-server/generated/prisma",
|
||||
"value": "/Users/bip/Documents/projects/projects_2026/wajs-server/generated/prisma",
|
||||
"fromEnvVar": null
|
||||
},
|
||||
"config": {
|
||||
@@ -211,7 +211,7 @@ const config = {
|
||||
}
|
||||
],
|
||||
"previewFeatures": [],
|
||||
"sourceFilePath": "/Users/bip/Documents/projects/jenna/wajs-server/prisma/schema.prisma",
|
||||
"sourceFilePath": "/Users/bip/Documents/projects/projects_2026/wajs-server/prisma/schema.prisma",
|
||||
"isCustomOutput": true
|
||||
},
|
||||
"relativeEnvPaths": {
|
||||
@@ -225,7 +225,6 @@ const config = {
|
||||
"db"
|
||||
],
|
||||
"activeProvider": "postgresql",
|
||||
"postinstall": true,
|
||||
"inlineDatasources": {
|
||||
"db": {
|
||||
"url": {
|
||||
|
||||
@@ -196,7 +196,7 @@ const config = {
|
||||
"value": "prisma-client-js"
|
||||
},
|
||||
"output": {
|
||||
"value": "/Users/bip/Documents/projects/jenna/wajs-server/generated/prisma",
|
||||
"value": "/Users/bip/Documents/projects/projects_2026/wajs-server/generated/prisma",
|
||||
"fromEnvVar": null
|
||||
},
|
||||
"config": {
|
||||
@@ -210,7 +210,7 @@ const config = {
|
||||
}
|
||||
],
|
||||
"previewFeatures": [],
|
||||
"sourceFilePath": "/Users/bip/Documents/projects/jenna/wajs-server/prisma/schema.prisma",
|
||||
"sourceFilePath": "/Users/bip/Documents/projects/projects_2026/wajs-server/prisma/schema.prisma",
|
||||
"isCustomOutput": true
|
||||
},
|
||||
"relativeEnvPaths": {
|
||||
@@ -224,7 +224,6 @@ const config = {
|
||||
"db"
|
||||
],
|
||||
"activeProvider": "postgresql",
|
||||
"postinstall": true,
|
||||
"inlineDatasources": {
|
||||
"db": {
|
||||
"url": {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "bun --hot src/index.tsx",
|
||||
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
|
||||
"start": "NODE_ENV=production bun src/index.tsx",
|
||||
"seed": "bun prisma/seed.ts"
|
||||
"seed": "bun prisma/seed.ts",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
@@ -16,6 +17,8 @@
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@lglab/react-qr-code": "^1.4.9",
|
||||
"@mantine/core": "^8.3.13",
|
||||
"@mantine/dates": "^8.3.14",
|
||||
"@mantine/form": "^8.3.14",
|
||||
"@mantine/hooks": "^8.3.13",
|
||||
"@mantine/modals": "^8.3.13",
|
||||
"@mantine/notifications": "^8.3.13",
|
||||
@@ -54,7 +57,7 @@
|
||||
"uuid": "^13.0.0",
|
||||
"whatsapp-api-js": "^6.2.1",
|
||||
"whatsapp-client-sdk": "^1.6.0",
|
||||
"whatsapp-web.js": "github:pedroslopez/whatsapp-web.js#main",
|
||||
"whatsapp-web.js": "^1.34.6",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
117
prisma/seed.ts
117
prisma/seed.ts
@@ -1,32 +1,93 @@
|
||||
import { prisma } from "@/server/lib/prisma";
|
||||
|
||||
const user = [
|
||||
{
|
||||
name: "wibu",
|
||||
email: "wibu@bip.com",
|
||||
password: "Production_123",
|
||||
}
|
||||
];
|
||||
export async function seed() {
|
||||
console.log("🌱 Starting seeding...");
|
||||
|
||||
; (async () => {
|
||||
for (const u of user) {
|
||||
await prisma.user.upsert({
|
||||
where: { email: u.email },
|
||||
create: u,
|
||||
update: u,
|
||||
// 1. Seed User from environment variables or defaults
|
||||
const adminEmail = process.env.ADMIN_EMAIL || "admin@example.com";
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: adminEmail },
|
||||
update: {},
|
||||
create: {
|
||||
name: "Administrator",
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
},
|
||||
});
|
||||
console.log(`✅ User seeded: ${user.email}`);
|
||||
|
||||
// 2. Seed Default API Key
|
||||
const defaultKey = "wajs_sk_live_default_key_2026";
|
||||
await prisma.apiKey.upsert({
|
||||
where: { key: defaultKey },
|
||||
update: {},
|
||||
create: {
|
||||
name: "Default API Key",
|
||||
key: defaultKey,
|
||||
userId: user.id,
|
||||
description: "Auto-generated default API key",
|
||||
},
|
||||
});
|
||||
console.log("✅ Default API Key seeded");
|
||||
|
||||
// 3. Seed Sample Webhook
|
||||
const webhookUrl = "https://webhook.site/wajs-test";
|
||||
const existingWebhook = await prisma.webHook.findFirst({
|
||||
where: { url: webhookUrl },
|
||||
});
|
||||
|
||||
if (!existingWebhook) {
|
||||
await prisma.webHook.create({
|
||||
data: {
|
||||
name: "Sample Webhook",
|
||||
url: webhookUrl,
|
||||
description: "Test webhook for capturing events",
|
||||
enabled: true,
|
||||
method: "POST",
|
||||
},
|
||||
});
|
||||
console.log("✅ Sample Webhook seeded");
|
||||
} else {
|
||||
console.log("ℹ️ Sample Webhook already exists, skipping");
|
||||
}
|
||||
|
||||
// 4. Seed Initial ChatFlow
|
||||
const flowUrl = "initial-flow";
|
||||
await prisma.chatFlows.upsert({
|
||||
where: { flowUrl: flowUrl },
|
||||
update: {},
|
||||
create: {
|
||||
flowUrl: flowUrl,
|
||||
flows: {
|
||||
nodes: [
|
||||
{
|
||||
id: "1",
|
||||
type: "input",
|
||||
data: { label: "Start" },
|
||||
position: { x: 250, y: 5 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
active: true,
|
||||
defaultFlow: "Main Flow",
|
||||
},
|
||||
});
|
||||
console.log("✅ Initial ChatFlow seeded");
|
||||
|
||||
console.log("✨ Seeding completed successfully!");
|
||||
return { user, defaultKey, webhookUrl, flowUrl };
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
seed()
|
||||
.catch((e) => {
|
||||
console.error("❌ Seeding failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
|
||||
console.log(`✅ User ${u.email} seeded successfully`)
|
||||
}
|
||||
|
||||
|
||||
})().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
}).finally(() => {
|
||||
console.log("✅ Seeding completed successfully ")
|
||||
process.exit(0)
|
||||
|
||||
})
|
||||
|
||||
// use fix-code
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import '@mantine/dates/styles.css';
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-mantine-color-scheme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
@@ -1,15 +1,163 @@
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import { Button, Container } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Text,
|
||||
Title,
|
||||
Stack,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
ThemeIcon,
|
||||
Paper,
|
||||
Box,
|
||||
rem,
|
||||
Divider,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandWhatsapp,
|
||||
IconRocket,
|
||||
IconShieldCheck,
|
||||
IconPlugConnected,
|
||||
IconArrowRight,
|
||||
} from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: IconBrandWhatsapp,
|
||||
title: "WhatsApp Integration",
|
||||
description: "Connect and automate WhatsApp messages effortlessly using wa-web.js technology.",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
icon: IconPlugConnected,
|
||||
title: "Webhooks & API",
|
||||
description: "Integrate with your existing systems via powerful webhooks and a developer-friendly API.",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
icon: IconRocket,
|
||||
title: "High Performance",
|
||||
description: "Built on Bun and ElysiaJS for maximum speed and efficient resource management.",
|
||||
color: "orange",
|
||||
},
|
||||
{
|
||||
icon: IconShieldCheck,
|
||||
title: "Secure & Reliable",
|
||||
description: "Manage API keys and sessions securely with a robust management dashboard.",
|
||||
color: "teal",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h1>Home</h1>
|
||||
<Button onClick={() => navigate(clientRoutes["/sq/dashboard"])}>
|
||||
Go to SQ
|
||||
</Button>
|
||||
</Container>
|
||||
<Box bg="var(--mantine-color-body)" style={{ minHeight: "100vh" }}>
|
||||
{/* Hero Section */}
|
||||
<Container size="lg" pt={{ base: 80, md: 120 }} pb={80}>
|
||||
<Stack align="center" gap="xl">
|
||||
<ThemeIcon size={80} radius="xl" color="green" variant="light">
|
||||
<IconBrandWhatsapp size={50} />
|
||||
</ThemeIcon>
|
||||
|
||||
<Box style={{ textAlign: "center" }}>
|
||||
<Title
|
||||
order={1}
|
||||
style={{
|
||||
fontSize: rem(60),
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.1,
|
||||
marginBottom: rem(20),
|
||||
}}
|
||||
>
|
||||
Master Your{" "}
|
||||
<Text
|
||||
component="span"
|
||||
variant="gradient"
|
||||
gradient={{ from: "green", to: "teal" }}
|
||||
inherit
|
||||
>
|
||||
WhatsApp
|
||||
</Text>{" "}
|
||||
Workflow
|
||||
</Title>
|
||||
|
||||
<Container size="sm" p={0}>
|
||||
<Text size="lg" c="dimmed" mb={40}>
|
||||
A robust, full-stack WhatsApp integration platform built with Bun.
|
||||
Send messages, manage webhooks, and automate your communication
|
||||
with ease.
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
<Group justify="center" mt="xl">
|
||||
<Button
|
||||
size="lg"
|
||||
color="green"
|
||||
radius="md"
|
||||
px={40}
|
||||
rightSection={<IconArrowRight size={20} />}
|
||||
onClick={() => navigate(clientRoutes["/sq/dashboard"])}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
px={40}
|
||||
onClick={() => navigate(clientRoutes["/login"])}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
{/* Features Section */}
|
||||
<Box bg="var(--mantine-color-dark-8)" py={80}>
|
||||
<Container size="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing={40}>
|
||||
{features.map((feature) => (
|
||||
<Paper key={feature.title} bg="transparent">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={feature.color}
|
||||
mb="md"
|
||||
>
|
||||
<feature.icon size={26} stroke={2} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="lg" mb="xs">
|
||||
{feature.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
|
||||
{feature.description}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Container size="lg" py="xl">
|
||||
<Divider mb="xl" variant="dotted" />
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
© 2026 wajs-server. Built with Bun & Mantine.
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge variant="dot" color="green">Production Ready</Badge>
|
||||
<Badge variant="dot" color="blue">v1.0.0</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,43 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Center,
|
||||
Box,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconAt, IconLock, IconLogin, IconBrandWhatsapp } from "@tabler/icons-react";
|
||||
import apiFetch from "../lib/apiFetch";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validate: {
|
||||
email: (value) => (/^\S+@\S+$/.test(value) ? null : "Invalid email"),
|
||||
password: (value) => (value.length < 1 ? "Password is required" : null),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function checkSession() {
|
||||
try {
|
||||
// backend otomatis baca cookie JWT dari request
|
||||
const res = await apiFetch.api.user.find.get();
|
||||
setIsAuthenticated(res.status === 200);
|
||||
} catch {
|
||||
@@ -32,54 +47,103 @@ export default function Login() {
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch.auth.login.post({
|
||||
email,
|
||||
password,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (response.data?.token) {
|
||||
localStorage.setItem("token", response.data.token);
|
||||
notifications.show({
|
||||
title: "Login Successful",
|
||||
message: "Welcome back!",
|
||||
color: "green",
|
||||
});
|
||||
window.location.href = clientRoutes["/sq/dashboard"];
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
alert(JSON.stringify(response.error));
|
||||
notifications.show({
|
||||
title: "Login Failed",
|
||||
message: (response.error as any)?.value?.message || "Invalid credentials",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notifications.show({
|
||||
title: "Error",
|
||||
message: "An unexpected error occurred",
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated === null) return null; // or loading spinner
|
||||
if (isAuthenticated === null) return null;
|
||||
if (isAuthenticated)
|
||||
return <Navigate to={clientRoutes["/sq/dashboard"]} replace />;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Text>Login</Text>
|
||||
<TextInput
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Group justify="right">
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
Login
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
<Box
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
background: "var(--mantine-color-body)",
|
||||
}}
|
||||
>
|
||||
<Container size={420} my={40}>
|
||||
<Stack gap="xs" mb={30} align="center">
|
||||
<ThemeIcon size={60} radius="xl" color="green" variant="light">
|
||||
<IconBrandWhatsapp size={40} />
|
||||
</ThemeIcon>
|
||||
<Title ta="center" order={2} fw={900}>
|
||||
Welcome Back!
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
Login to manage your WhatsApp integration
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Email address"
|
||||
placeholder="hello@gmail.com"
|
||||
required
|
||||
leftSection={<IconAt size={16} />}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
leftSection={<IconLock size={16} />}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Group justify="space-between" mt="lg">
|
||||
<Button
|
||||
fullWidth
|
||||
type="submit"
|
||||
loading={loading}
|
||||
leftSection={<IconLogin size={18} />}
|
||||
radius="md"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,306 +1,342 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ScrollArea,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Loader,
|
||||
ActionIcon,
|
||||
Center,
|
||||
Title,
|
||||
Tooltip
|
||||
} from "@mantine/core";
|
||||
import { IconKey, IconPlus, IconTrash, IconCopy } from "@tabler/icons-react";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconCalendar,
|
||||
IconCopy,
|
||||
IconInfoCircle,
|
||||
IconKey,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
|
||||
export default function ApiKeyPage() {
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const toggleRefresh = () => setRefresh((r) => !r);
|
||||
|
||||
return (
|
||||
<Container
|
||||
w={"100%"}
|
||||
size="lg"
|
||||
px="md"
|
||||
py="xl"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(800px 400px at 10% 10%, rgba(0,255,200,0.05), transparent), radial-gradient(800px 400px at 90% 90%, rgba(0,255,255,0.04), transparent), linear-gradient(180deg, #0f0f0f 0%, #191919 100%)",
|
||||
borderRadius: "20px",
|
||||
boxShadow: "0 0 60px rgba(0,255,200,0.04)",
|
||||
color: "#EAEAEA",
|
||||
minHeight: "90vh",
|
||||
}}
|
||||
>
|
||||
<Container w="100%" size="xl" py="xl">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<IconKey size={28} color="#00FFC8" />
|
||||
<Text fw={700} fz={26} c="#EAEAEA">
|
||||
API Key Management
|
||||
</Text>
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<IconKey size={32} color="var(--mantine-color-teal-filled)" />
|
||||
<Title order={1} fw={900}>
|
||||
API Key Management
|
||||
</Title>
|
||||
</Group>
|
||||
<Text c="dimmed" fz="sm">
|
||||
Generate and manage secure access keys for your integrations.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconInfoCircle size={14} />}
|
||||
>
|
||||
Secure Access
|
||||
</Badge>
|
||||
</Group>
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="lg"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(0,255,200,0.08), rgba(0,255,255,0.05))",
|
||||
border: "1px solid rgba(0,255,220,0.2)",
|
||||
color: "#00FFC8",
|
||||
}}
|
||||
>
|
||||
Secure Access
|
||||
</Badge>
|
||||
</Group>
|
||||
<Divider color="rgba(0,255,200,0.1)" />
|
||||
<CreateApiKey />
|
||||
<Divider mt="md" variant="dotted" />
|
||||
</Box>
|
||||
|
||||
<CreateApiKey onCreated={toggleRefresh} />
|
||||
<ListApiKey refresh={refresh} />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateApiKey() {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [expiredAt, setExpiredAt] = useState("");
|
||||
function CreateApiKey({ onCreated }: { onCreated: () => void }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
showNotification({
|
||||
title: "Missing name",
|
||||
message: "Please enter a name for your API key",
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
expiredAt: null as Date | null,
|
||||
},
|
||||
validate: {
|
||||
name: (value) => (value.trim().length < 3 ? "Name must be at least 3 characters" : null),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch.api.apikey.create.post({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
expiredAt: values.expiredAt ? values.expiredAt.toISOString() : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
form.reset();
|
||||
notifications.show({
|
||||
title: "Success",
|
||||
message: "API key created successfully",
|
||||
color: "teal",
|
||||
});
|
||||
onCreated();
|
||||
} else {
|
||||
notifications.show({
|
||||
title: "Error",
|
||||
message: (res.error as any)?.message || "Failed to create API key",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notifications.show({
|
||||
title: "Error",
|
||||
message: "An unexpected error occurred",
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const res = await apiFetch.api.apikey.create.post({
|
||||
name,
|
||||
description,
|
||||
expiredAt,
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.status === 200) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setExpiredAt("");
|
||||
showNotification({
|
||||
title: "Success",
|
||||
message: "API key created successfully",
|
||||
color: "teal",
|
||||
});
|
||||
setRefresh((r) => !r);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Card
|
||||
p="xl"
|
||||
radius="lg"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01))",
|
||||
border: "1px solid rgba(0,255,200,0.1)",
|
||||
boxShadow: "0 0 30px rgba(0,255,200,0.05)",
|
||||
backdropFilter: "blur(6px)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} fz="lg" c="#EAEAEA">
|
||||
Create New API Key
|
||||
</Text>
|
||||
<IconPlus size={22} color="#00FFC8" />
|
||||
</Group>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Key Name"
|
||||
placeholder="Enter key name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="Describe the key purpose"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Expiration Date"
|
||||
placeholder="YYYY-MM-DD"
|
||||
type="date"
|
||||
value={expiredAt}
|
||||
onChange={(e) => setExpiredAt(e.target.value)}
|
||||
/>
|
||||
<Group justify="right" mt="md">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setExpiredAt("");
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #00FFC8 0%, #00FFFF 100%)",
|
||||
color: "#191919",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Save Key
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Paper withBorder shadow="sm" p="xl" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700} fz="lg">
|
||||
Create New API Key
|
||||
</Text>
|
||||
<IconPlus size={20} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
|
||||
<ListApiKey refresh={refresh} />
|
||||
</Stack>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Group align="flex-start" grow>
|
||||
<TextInput
|
||||
label="Key Name"
|
||||
placeholder="e.g. Production Webhook"
|
||||
required
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="What is this key for?"
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
<DateInput
|
||||
label="Expiration Date"
|
||||
placeholder="Optional expiration"
|
||||
leftSection={<IconCalendar size={16} />}
|
||||
clearable
|
||||
{...form.getInputProps("expiredAt")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="right" mt="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => form.reset()}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="teal"
|
||||
>
|
||||
Generate Key
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ListApiKey({ refresh }: { refresh: boolean }) {
|
||||
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchApiKeys = async () => {
|
||||
setLoading(true);
|
||||
const fetchApiKeys = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch.api.apikey.list.get();
|
||||
if (res.status === 200) {
|
||||
setApiKeys(res.data?.apiKeys || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchApiKeys();
|
||||
}, [refresh]);
|
||||
|
||||
const filteredKeys = apiKeys.filter((key) =>
|
||||
key.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res = await apiFetch.api.apikey.delete.delete({ id });
|
||||
if (res.status === 200) {
|
||||
setApiKeys((prev) => prev.filter((a) => a.id !== id));
|
||||
notifications.show({
|
||||
title: "Deleted",
|
||||
message: "API key removed successfully",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notifications.show({
|
||||
title: "Error",
|
||||
message: "Failed to delete API key",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
notifications.show({
|
||||
title: "Copied",
|
||||
message: "API key copied to clipboard",
|
||||
color: "teal",
|
||||
icon: <IconCopy size={16} />,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="xl"
|
||||
radius="lg"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01))",
|
||||
border: "1px solid rgba(0,255,200,0.1)",
|
||||
boxShadow: "0 0 30px rgba(0,255,200,0.05)",
|
||||
backdropFilter: "blur(6px)",
|
||||
}}
|
||||
>
|
||||
<Paper withBorder shadow="sm" p="xl" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} fz="lg" c="#EAEAEA">
|
||||
<Text fw={700} fz="lg">
|
||||
Active API Keys
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="Search keys..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
size="xs"
|
||||
w={250}
|
||||
/>
|
||||
</Group>
|
||||
<Divider color="rgba(0,255,200,0.05)" />
|
||||
|
||||
<Divider variant="dotted" />
|
||||
|
||||
{loading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
<Center py={50}>
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader color="teal" size="lg" type="dots" />
|
||||
<Text c="dimmed" fz="sm">
|
||||
Fetching your keys...
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Text c="#9A9A9A">No API keys found</Text>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<Center py={50}>
|
||||
<Stack align="center" gap="xs">
|
||||
<IconKey size={48} color="var(--mantine-color-gray-4)" />
|
||||
<Text fw={500} c="dimmed">
|
||||
{search ? "No keys match your search" : "No API keys created yet"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollArea>
|
||||
<Table
|
||||
highlightOnHover
|
||||
verticalSpacing="sm"
|
||||
horizontalSpacing="md"
|
||||
style={{
|
||||
color: "#EAEAEA",
|
||||
borderCollapse: "separate",
|
||||
borderSpacing: "0 8px",
|
||||
}}
|
||||
>
|
||||
<Table highlightOnHover verticalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Expired</Table.Th>
|
||||
<Table.Th>Expiration</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
<Table.Th>Updated</Table.Th>
|
||||
<Table.Th align="right">Actions</Table.Th>
|
||||
<Table.Th align="right" style={{ textAlign: "right" }}>
|
||||
Actions
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{apiKeys.map((apiKey: any, index: number) => (
|
||||
<Table.Tr
|
||||
key={index}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
borderRadius: 10,
|
||||
transition: "background 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<Table.Td>{apiKey.name}</Table.Td>
|
||||
<Table.Td c="#9A9A9A">{apiKey.description || "—"}</Table.Td>
|
||||
{filteredKeys.map((apiKey: any) => (
|
||||
<Table.Tr key={apiKey.id}>
|
||||
<Table.Td>
|
||||
{apiKey.expiredAt
|
||||
? new Date(apiKey.expiredAt).toISOString().split("T")[0]
|
||||
: "—"}
|
||||
<Text fw={600} fz="sm">
|
||||
{apiKey.name}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{new Date(apiKey.createdAt).toISOString().split("T")[0]}
|
||||
<Text fz="sm" c="dimmed" lineClamp={1}>
|
||||
{apiKey.description || "No description"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{new Date(apiKey.updatedAt).toISOString().split("T")[0]}
|
||||
{apiKey.expiredAt ? (
|
||||
<Badge
|
||||
color={dayjs().isAfter(dayjs(apiKey.expiredAt)) ? "red" : "gray"}
|
||||
variant="dot"
|
||||
>
|
||||
{dayjs(apiKey.expiredAt).format("MMM DD, YYYY")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text fz="xs" c="dimmed">
|
||||
Never
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Group gap={4} justify="right">
|
||||
<Tooltip label="Copy Key" withArrow>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{dayjs(apiKey.createdAt).format("MMM DD, YYYY")}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={8} justify="right">
|
||||
<Tooltip label="Copy API Key" withArrow position="top">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey.key);
|
||||
showNotification({
|
||||
title: "Copied",
|
||||
message: "API key copied to clipboard",
|
||||
color: "teal",
|
||||
});
|
||||
}}
|
||||
onClick={() => handleCopy(apiKey.key)}
|
||||
size="lg"
|
||||
>
|
||||
<IconCopy size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete Key" withArrow>
|
||||
<Tooltip label="Delete API Key" withArrow position="top">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
await apiFetch.api.apikey.delete.delete({
|
||||
id: apiKey.id,
|
||||
});
|
||||
setApiKeys((prev) =>
|
||||
prev.filter((a) => a.id !== apiKey.id),
|
||||
);
|
||||
showNotification({
|
||||
title: "Deleted",
|
||||
message: "API key removed successfully",
|
||||
color: "red",
|
||||
});
|
||||
}}
|
||||
onClick={() => handleDelete(apiKey.id)}
|
||||
size="lg"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
@@ -314,6 +350,6 @@ function ListApiKey({ refresh }: { refresh: boolean }) {
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,177 +1,113 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Box,
|
||||
Burger,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Menu,
|
||||
NavLink,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
Badge,
|
||||
UnstyledButton,
|
||||
rem
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconChevronLeft,
|
||||
IconBrandWhatsapp,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDashboard,
|
||||
IconHome,
|
||||
IconKey,
|
||||
IconWebhook,
|
||||
IconBrandWhatsapp,
|
||||
IconUser,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconWebhook,
|
||||
} from "@tabler/icons-react";
|
||||
import type { User } from "generated/prisma";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
|
||||
function Logout() {
|
||||
return (
|
||||
<Group justify="center" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="compact-sm"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={async () => {
|
||||
await apiFetch.auth.logout.delete();
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const [opened, setOpened] = useLocalStorage({
|
||||
key: "nav_open",
|
||||
defaultValue: true,
|
||||
});
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
padding="lg"
|
||||
header={{ height: 60 }}
|
||||
navbar={{
|
||||
width: 270,
|
||||
width: 280,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !opened, desktop: !opened },
|
||||
}}
|
||||
styles={{
|
||||
main: {
|
||||
background: "#191919",
|
||||
color: "#EAEAEA",
|
||||
},
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Navbar
|
||||
p="md"
|
||||
style={{
|
||||
background: "rgba(30,30,30,0.8)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderRight: "1px solid rgba(0,255,200,0.15)",
|
||||
// boxShadow: "0 0 18px rgba(0,255,200,0.1)",
|
||||
}}
|
||||
>
|
||||
<AppShell.Section>
|
||||
<Group justify="flex-end" p="xs">
|
||||
<Tooltip
|
||||
label={opened ? "Collapse navigation" : "Expand navigation"}
|
||||
withArrow
|
||||
color="cyan"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="xl"
|
||||
onClick={() => setOpened((v) => !v)}
|
||||
aria-label="Toggle navigation"
|
||||
style={{
|
||||
color: "#00FFC8",
|
||||
background: "rgba(0,255,200,0.1)",
|
||||
// boxShadow: "0 0 10px rgba(0,255,200,0.2)",
|
||||
}}
|
||||
>
|
||||
{opened ? <IconChevronLeft /> : <IconChevronRight />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<AppShell.Header p="md">
|
||||
<Group h="100%" justify="space-between">
|
||||
<Group>
|
||||
<Burger
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Burger
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="teal" size="sm">
|
||||
<IconBrandWhatsapp size={18} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} fw={800} visibleFrom="xs">
|
||||
WAJS SERVER
|
||||
</Title>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section grow component={ScrollArea}>
|
||||
<Group>
|
||||
<HostHeaderView />
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="xs">
|
||||
<AppShell.Section grow component={ScrollArea} mx="-xs" px="xs">
|
||||
<NavigationDashboard />
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section>
|
||||
<HostView />
|
||||
<Divider my="sm" />
|
||||
<NavigationFooter />
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Stack gap="md">
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="lg"
|
||||
radius="xl"
|
||||
p="md"
|
||||
style={{
|
||||
background: "rgba(45,45,45,0.6)",
|
||||
backdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(0,255,200,0.2)",
|
||||
}}
|
||||
>
|
||||
<Flex align="center" gap="md">
|
||||
{!opened && (
|
||||
<Tooltip label="Open navigation menu" withArrow color="cyan">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="xl"
|
||||
onClick={() => setOpened(true)}
|
||||
aria-label="Open navigation"
|
||||
style={{
|
||||
color: "#00FFFF",
|
||||
background: "rgba(0,255,200,0.1)",
|
||||
}}
|
||||
>
|
||||
<IconChevronRight />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Title order={3} fw={600} c="#EAEAEA">
|
||||
Control Center
|
||||
</Title>
|
||||
<Badge
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
style={{
|
||||
background: "rgba(0,255,200,0.15)",
|
||||
color: "#00FFFF",
|
||||
}}
|
||||
>
|
||||
Live
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Paper>
|
||||
<AppShell.Main bg="var(--mantine-color-dark-9)">
|
||||
<Box
|
||||
style={{
|
||||
maxWidth: 1200,
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Stack>
|
||||
</Box>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function HostView() {
|
||||
function HostHeaderView() {
|
||||
const [host, setHost] = useState<User | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchHost() {
|
||||
@@ -181,51 +117,49 @@ function HostView() {
|
||||
fetchHost();
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await apiFetch.auth.logout.delete();
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
if (!host) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
radius="xl"
|
||||
withBorder
|
||||
shadow="md"
|
||||
p="md"
|
||||
style={{
|
||||
background: "rgba(45,45,45,0.6)",
|
||||
border: "1px solid rgba(0,255,200,0.15)",
|
||||
// boxShadow: "0 0 12px rgba(0,255,200,0.1)",
|
||||
}}
|
||||
>
|
||||
{host ? (
|
||||
<Stack gap="sm">
|
||||
<Flex gap="md" align="center">
|
||||
<Avatar
|
||||
size="lg"
|
||||
radius="xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(0,255,200,0.3), rgba(0,255,255,0.4))",
|
||||
color: "#EAEAEA",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<Menu shadow="md" width={200} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap="xs">
|
||||
<Avatar color="teal" radius="xl" size="sm">
|
||||
{host.name?.[0]}
|
||||
</Avatar>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} c="#EAEAEA">
|
||||
<Box visibleFrom="sm" style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{host.name}
|
||||
</Text>
|
||||
<Text size="sm" c="#9A9A9A">
|
||||
{host.email}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
<Divider color="rgba(0,255,200,0.2)" />
|
||||
<Logout />
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="#9A9A9A" ta="center">
|
||||
Host data unavailable
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Box>
|
||||
<IconChevronDown size={14} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Application</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconSettings style={{ width: rem(14), height: rem(14) }} />}
|
||||
>
|
||||
Settings
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
onClick={handleLogout}
|
||||
leftSection={<IconLogout style={{ width: rem(14), height: rem(14) }} />}
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,61 +171,63 @@ function NavigationDashboard() {
|
||||
{
|
||||
path: "/sq/dashboard/dashboard",
|
||||
label: "Overview",
|
||||
icon: <IconDashboard size={20} color="#00FFFF" />,
|
||||
desc: "Main dashboard insights",
|
||||
icon: <IconDashboard size={20} />,
|
||||
},
|
||||
{
|
||||
path: "/sq/dashboard/wajs/wajs-home",
|
||||
label: "WhatsApp Service",
|
||||
icon: <IconBrandWhatsapp size={20} />,
|
||||
},
|
||||
{
|
||||
path: "/sq/dashboard/wa-hook/wa-hook-home",
|
||||
label: "Hook Activity",
|
||||
icon: <IconWebhook size={20} />,
|
||||
},
|
||||
{
|
||||
path: "/sq/dashboard/webhook/webhook-home",
|
||||
label: "Webhooks Config",
|
||||
icon: <IconSettings size={20} />,
|
||||
},
|
||||
{
|
||||
path: "/sq/dashboard/apikey/apikey",
|
||||
label: "API Keys",
|
||||
icon: <IconKey size={20} color="#00FFFF" />,
|
||||
desc: "Manage and regenerate access tokens",
|
||||
},
|
||||
{
|
||||
path: "/sq/dashboard/wajs/wajs-home",
|
||||
label: "Wajs Integration",
|
||||
icon: <IconBrandWhatsapp size={20} color="#00FFFF" />,
|
||||
desc: "WhatsApp session manager",
|
||||
},
|
||||
{
|
||||
path: "/sq/dashboard/webhook/webhook-home",
|
||||
label: "Webhooks",
|
||||
icon: <IconWebhook size={20} color="#00FFFF" />,
|
||||
desc: "Incoming and outgoing event handlers",
|
||||
},
|
||||
{
|
||||
path: clientRoutes["/sq/dashboard/wa-hook/wa-hook-home"],
|
||||
label: "WA Hook",
|
||||
icon: <IconWebhook size={20} color="#00FFFF" />,
|
||||
desc: "WA Hook",
|
||||
icon: <IconKey size={20} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Stack gap={4}>
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
active={location.pathname.startsWith(item.path)}
|
||||
leftSection={item.icon}
|
||||
label={item.label}
|
||||
description={item.desc}
|
||||
onClick={() =>
|
||||
navigate(clientRoutes[item.path as keyof typeof clientRoutes])
|
||||
}
|
||||
style={{
|
||||
borderRadius: "12px",
|
||||
color: "#EAEAEA",
|
||||
background: location.pathname.startsWith(item.path)
|
||||
? "rgba(0,255,200,0.15)"
|
||||
: "transparent",
|
||||
transition: "background 0.2s ease",
|
||||
}}
|
||||
styles={{
|
||||
label: { fontWeight: 500, color: "#EAEAEA" },
|
||||
description: { color: "#9A9A9A" },
|
||||
}}
|
||||
variant="light"
|
||||
color="teal"
|
||||
style={{ borderRadius: rem(8) }}
|
||||
rightSection={<IconChevronRight size={14} stroke={1.5} />}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationFooter() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<NavLink
|
||||
leftSection={<IconHome size={20} />}
|
||||
label="Back to Home"
|
||||
onClick={() => navigate("/")}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
style={{ borderRadius: rem(8) }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,219 @@
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Container,
|
||||
Grid,
|
||||
Paper,
|
||||
Group,
|
||||
Stack,
|
||||
ThemeIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Box,
|
||||
SimpleGrid,
|
||||
List,
|
||||
ThemeIcon as MantineThemeIcon,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconDashboard,
|
||||
IconBrandWhatsapp,
|
||||
IconKey,
|
||||
IconWebhook,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconPlayerPlay,
|
||||
IconArrowRight,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import useSWR from "swr";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { data: waState } = useSWR("/wa/state", apiFetch.api.wa.state.get, {
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
const isWaReady = waState?.data?.state?.ready;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
<Stack gap="xl" py="sm">
|
||||
{/* Header Section */}
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={4}>
|
||||
<Title order={2} fw={900}>
|
||||
System Overview
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Welcome to your WhatsApp Integration Control Center.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="dot"
|
||||
color={isWaReady ? "green" : "red"}
|
||||
p="md"
|
||||
>
|
||||
System {isWaReady ? "Online" : "Action Required"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Main Stats / Status Cards */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatusCard
|
||||
title="WhatsApp Service"
|
||||
status={isWaReady ? "Connected" : "Disconnected"}
|
||||
color={isWaReady ? "green" : "red"}
|
||||
icon={IconBrandWhatsapp}
|
||||
description={isWaReady ? "Active and receiving hooks" : "Start service to begin"}
|
||||
actionLabel={isWaReady ? "Manage" : "Start Now"}
|
||||
onAction={() => navigate(clientRoutes["/sq/dashboard/wajs/wajs-home"])}
|
||||
/>
|
||||
<StatusCard
|
||||
title="API Keys"
|
||||
status="Active"
|
||||
color="blue"
|
||||
icon={IconKey}
|
||||
description="3 keys currently active"
|
||||
actionLabel="View Keys"
|
||||
onAction={() => navigate(clientRoutes["/sq/dashboard/apikey/apikey"])}
|
||||
/>
|
||||
<StatusCard
|
||||
title="Webhooks"
|
||||
status="Enabled"
|
||||
color="violet"
|
||||
icon={IconWebhook}
|
||||
description="8 endpoints configured"
|
||||
actionLabel="Configure"
|
||||
onAction={() => navigate(clientRoutes["/sq/dashboard/webhook/webhook-home"])}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid gutter="xl">
|
||||
{/* Getting Started / Guide */}
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Paper withBorder p="xl" radius="md" h="100%">
|
||||
<Title order={3} mb="lg">Getting Started</Title>
|
||||
<Stack gap="md">
|
||||
<List
|
||||
spacing="md"
|
||||
size="sm"
|
||||
center
|
||||
icon={
|
||||
<ThemeIcon color="teal" size={24} radius="xl">
|
||||
<IconCircleCheck size={16} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<List.Item>
|
||||
<Text fw={600}>Scan WhatsApp QR Code</Text>
|
||||
<Text size="xs" c="dimmed">Go to WhatsApp Service and scan the QR to link your device.</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text fw={600}>Generate API Key</Text>
|
||||
<Text size="xs" c="dimmed">Create a secure key to authenticate your external requests.</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text fw={600}>Configure Webhooks</Text>
|
||||
<Text size="xs" c="dimmed">Set up URLs to receive real-time notifications for incoming messages.</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text fw={600}>Start Automation</Text>
|
||||
<Text size="xs" c="dimmed">Your system is now ready to send and receive messages automatically.</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
mt="md"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate(clientRoutes["/sq/dashboard/wajs/wajs-home"])}
|
||||
>
|
||||
Go to WhatsApp Service
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Quick Tips / Info */}
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Stack h="100%">
|
||||
<Paper withBorder p="xl" radius="md" bg="var(--mantine-color-dark-8)">
|
||||
<Group mb="xs">
|
||||
<ThemeIcon variant="light" color="blue">
|
||||
<IconSettings size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Developer Pro Tip</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
|
||||
You can use the API Key in the "Authorization" header as a Bearer token
|
||||
to send messages programmatically via our REST API.
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="xl" radius="md" flex={1}>
|
||||
<Title order={4} mb="xs">System Health</Title>
|
||||
<Stack gap="xs">
|
||||
<HealthItem label="Database" status="Healthy" color="green" />
|
||||
<HealthItem label="WhatsApp Client" status={isWaReady ? "Online" : "Offline"} color={isWaReady ? "green" : "red"} />
|
||||
<HealthItem label="Webhook Dispatcher" status="Active" color="green" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusCardProps {
|
||||
title: string;
|
||||
status: string;
|
||||
color: string;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
function StatusCard({ title, status, color, icon: Icon, description, actionLabel, onAction }: StatusCardProps) {
|
||||
return (
|
||||
<Paper withBorder p="lg" radius="md" shadow="sm">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color={color}>
|
||||
<Icon size={24} />
|
||||
</ThemeIcon>
|
||||
<Badge color={color} variant="light">{status}</Badge>
|
||||
</Group>
|
||||
|
||||
<Box>
|
||||
<Text fw={700} size="lg">{title}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>{description}</Text>
|
||||
</Box>
|
||||
|
||||
<Button variant="subtle" color={color} fullWidth mt="xs" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthItem({ label, status, color }: { label: string; status: string; color: string }) {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{label}</Text>
|
||||
<Group gap={6}>
|
||||
<Box w={8} h={8} bg={`${color}.6`} style={{ borderRadius: "50%" }} />
|
||||
<Text size="xs" fw={700} c={color}>{status}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
rem,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Tooltip
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage, useShallowEffect } from "@mantine/hooks";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconMessageCircle,
|
||||
IconUser,
|
||||
IconCalendar,
|
||||
IconActivity,
|
||||
IconHash,
|
||||
IconCode,
|
||||
IconMessageCircle,
|
||||
IconPhone,
|
||||
IconRefresh,
|
||||
IconRobot,
|
||||
IconTrash,
|
||||
IconUser
|
||||
} from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import useSWR from "swr";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function WaHookHome() {
|
||||
const [page, setPage] = useLocalStorage({ key: "wa-hook-page", defaultValue: 1 });
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
@@ -43,195 +53,202 @@ export default function WaHookHome() {
|
||||
mutate();
|
||||
}, [page]);
|
||||
|
||||
async function handleReset() {
|
||||
await apiFetch["wa-hook"].reset.post();
|
||||
mutate();
|
||||
showNotification({
|
||||
title: "Reset Completed",
|
||||
message: "All WhatsApp Hook data has been cleared.",
|
||||
color: "teal",
|
||||
const handleReset = () => {
|
||||
modals.openConfirmModal({
|
||||
title: "Clear Activity Logs",
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to clear all WhatsApp activity logs? This action cannot be undone.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: "Clear All", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
await apiFetch["wa-hook"].reset.post();
|
||||
mutate();
|
||||
notifications.show({
|
||||
title: "Logs Cleared",
|
||||
message: "All activity data has been successfully removed.",
|
||||
color: "teal",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<Stack gap="md" py="sm">
|
||||
<Skeleton height={100} radius="md" />
|
||||
<Skeleton height={150} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (isLoading) return <Skeleton height={600} radius="lg" />;
|
||||
if (error)
|
||||
return (
|
||||
<Container p="xl">
|
||||
<Text c="red.5" ta="center" fz="lg" fw={500}>
|
||||
Failed to load webhook data.
|
||||
</Text>
|
||||
</Container>
|
||||
<Center py={100}>
|
||||
<Paper withBorder p="xl" radius="md">
|
||||
<Text c="red" fw={600}>Failed to load activity data</Text>
|
||||
<Button variant="light" color="red" mt="md" onClick={() => mutate()}>Retry</Button>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container
|
||||
size="lg"
|
||||
p="xl"
|
||||
style={{
|
||||
background: "linear-gradient(145deg, #1a1a1a 0%, #111 100%)",
|
||||
borderRadius: 24,
|
||||
border: "1px solid rgba(0,255,200,0.15)",
|
||||
boxShadow: "0 0 30px rgba(0,255,200,0.1)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2}>
|
||||
<Title order={2} c="#EAEAEA" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||
WhatsApp Hook Monitor
|
||||
</Title>
|
||||
<Text c="#9A9A9A" fz="sm">
|
||||
Real-time webhook activity and message tracking
|
||||
<Stack gap="xl" py="sm">
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="teal" size="lg">
|
||||
<IconActivity size={20} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} fw={900}>
|
||||
Hook Activity Monitor
|
||||
</Title>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
Track real-time WhatsApp messages and AI flow responses.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Tooltip label="Reset all webhook data" withArrow color="teal">
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Refresh" withArrow>
|
||||
<ActionIcon variant="default" size="lg" onClick={() => mutate()}>
|
||||
<IconRefresh size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconTrash size={18} />}
|
||||
onClick={handleReset}
|
||||
leftSection={<IconRefresh size={18} />}
|
||||
variant="gradient"
|
||||
gradient={{ from: "#00FFC8", to: "#00FFFF", deg: 45 }}
|
||||
radius="xl"
|
||||
size="md"
|
||||
>
|
||||
Reset Data
|
||||
Clear Logs
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider mt="md" variant="dotted" />
|
||||
</Box>
|
||||
|
||||
<Divider color="rgba(0,255,200,0.2)" />
|
||||
<Stack gap="md">
|
||||
{data?.data?.list?.length ? (
|
||||
data.data.list.map((item) => {
|
||||
let parsed: any = {};
|
||||
try {
|
||||
parsed = typeof item.data === 'string' ? JSON.parse(item.data) : item.data;
|
||||
} catch (e) {
|
||||
parsed = {};
|
||||
}
|
||||
|
||||
{/* <pre>{JSON.stringify(data?.data?.list, null, 2)}</pre> */}
|
||||
return (
|
||||
<Paper key={item.id} withBorder p="lg" radius="md" shadow="xs">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<Avatar color="teal" radius="xl">
|
||||
<IconUser size={20} />
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Text fw={700} fz="sm">
|
||||
{parsed.name || "Unknown Sender"}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<IconPhone size={12} color="var(--mantine-color-dimmed)" />
|
||||
<Text fz="xs" c="dimmed">
|
||||
{parsed.number || "No number"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Box style={{ textAlign: "right" }}>
|
||||
<Text fz="xs" c="dimmed" fw={500}>
|
||||
{dayjs(item.createdAt).format("MMM DD, HH:mm:ss")}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{dayjs(item.createdAt).fromNow()}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Stack gap="md">
|
||||
{data?.data?.list?.length ? (
|
||||
data.data.list.map((item) => {
|
||||
const parsed = JSON.parse((item.data as any) || "{}");
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(160deg, rgba(45,45,45,0.9) 0%, rgba(25,25,25,0.95) 100%)",
|
||||
border: "1px solid rgba(0,255,200,0.25)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={8}>
|
||||
{/* Nama & Nomor Pengirim */}
|
||||
<Group gap="xs" align="center">
|
||||
<IconUser size={16} color="#00FFC8" />
|
||||
<Text c="#EAEAEA" fw={500}>
|
||||
{parsed.name || "Unknown Sender"} ({parsed.number || "No Number"})
|
||||
<Box
|
||||
p="md"
|
||||
bg="var(--mantine-color-dark-8)"
|
||||
style={{ borderRadius: rem(8), borderLeft: "4px solid var(--mantine-color-teal-6)" }}
|
||||
>
|
||||
<Group gap="xs" mb={4}>
|
||||
<IconMessageCircle size={14} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" fw={700} c="teal" tt="uppercase">
|
||||
Inbound Message
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="sm">{parsed.question || "(Empty message)"}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Pertanyaan / Pesan */}
|
||||
<Group gap="xs" align="center">
|
||||
<IconMessageCircle size={16} color="#00FFFF" />
|
||||
<Text c="#9A9A9A" fz="sm">
|
||||
{parsed.question || "(No question)"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* ID Record */}
|
||||
<Group gap="xs" align="center">
|
||||
<IconHash size={16} color="#00FFC8" />
|
||||
<Text c="#9A9A9A" fz="xs">
|
||||
{item.id}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Timestamp */}
|
||||
<Group gap="xs" align="center">
|
||||
<IconCalendar size={16} color="#00FFFF" />
|
||||
<Text c="#9A9A9A" fz="xs">
|
||||
{dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Flow ID */}
|
||||
{parsed.flowId && (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCode size={16} color="#B554FF" />
|
||||
<Badge
|
||||
color="grape"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
styles={{
|
||||
root: { backgroundColor: "rgba(181,84,255,0.15)", color: "#EAEAEA" },
|
||||
}}
|
||||
>
|
||||
Flow: {parsed.flowId}
|
||||
</Badge>
|
||||
{parsed.answer && (
|
||||
<Box
|
||||
p="md"
|
||||
bg="var(--mantine-color-teal-9)"
|
||||
style={{
|
||||
borderRadius: rem(8),
|
||||
borderLeft: "4px solid var(--mantine-color-blue-6)",
|
||||
marginLeft: rem(20)
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap="xs">
|
||||
<IconRobot size={16} color="var(--mantine-color-blue-4)" />
|
||||
<Text fz="xs" fw={700} c="blue.4" tt="uppercase">
|
||||
AI Response
|
||||
</Text>
|
||||
</Group>
|
||||
{parsed.flowId && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Flow: {parsed.flowId}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Text fz="sm">{parsed.answer}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Jawaban */}
|
||||
{parsed.answer && (
|
||||
<Card
|
||||
p="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: "rgba(45,45,45,0.7)",
|
||||
border: "1px solid rgba(0,255,255,0.15)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Text c="#EAEAEA" fw={500} fz="sm">
|
||||
Bot Answer
|
||||
</Text>
|
||||
<Text c="#EAEAEA" fz="sm">
|
||||
{parsed.answer}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Card
|
||||
radius="lg"
|
||||
style={{
|
||||
backgroundColor: "#2D2D2D",
|
||||
border: "1px solid rgba(0,255,255,0.1)",
|
||||
textAlign: "center",
|
||||
padding: 60,
|
||||
}}
|
||||
>
|
||||
<Text c="#9A9A9A" fz="lg">
|
||||
No webhook activity detected yet.
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<IconHash size={12} color="var(--mantine-color-dimmed)" />
|
||||
<Text fz="xs" c="dimmed" ff="monospace">
|
||||
ID: {item.id}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Center py={80}>
|
||||
<Stack align="center" gap="xs">
|
||||
<IconActivity size={48} color="var(--mantine-color-gray-4)" />
|
||||
<Text fw={500} c="dimmed">
|
||||
No hook activity detected yet.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
</Stack>
|
||||
|
||||
<Group justify="center" mt="xl">
|
||||
<Pagination
|
||||
value={page}
|
||||
total={Math.ceil((data?.data?.count || 1) / 10)}
|
||||
onChange={(value) => {
|
||||
setPage(value);
|
||||
mutate();
|
||||
}}
|
||||
radius="xl"
|
||||
withEdges
|
||||
color="teal"
|
||||
size="md"
|
||||
styles={{
|
||||
control: {
|
||||
backgroundColor: "#2D2D2D",
|
||||
border: "1px solid rgba(0,255,200,0.15)",
|
||||
color: "#EAEAEA",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
<Group justify="center" mt="xl">
|
||||
<Pagination
|
||||
value={page}
|
||||
total={Math.ceil((data?.data?.count || 1) / 10)}
|
||||
onChange={(value) => {
|
||||
setPage(value);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
color="teal"
|
||||
size="sm"
|
||||
radius="md"
|
||||
withEdges
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default function WaHookLayout() {
|
||||
return (
|
||||
<Container size="xl" w={"100%"}>
|
||||
<Group justify="flex-start" p={"md"}>
|
||||
<Button
|
||||
{/* <Button
|
||||
color="cyan"
|
||||
size="xs"
|
||||
radius={"lg"}
|
||||
@@ -16,7 +16,7 @@ export default function WaHookLayout() {
|
||||
}
|
||||
>
|
||||
Flow WA Hook
|
||||
</Button>
|
||||
</Button> */}
|
||||
</Group>
|
||||
<Outlet />
|
||||
</Container>
|
||||
|
||||
@@ -1,3 +1,326 @@
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
TextInput,
|
||||
Textarea,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconBolt,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconKey,
|
||||
IconMessage2,
|
||||
IconWebhook,
|
||||
IconSend,
|
||||
IconPhone,
|
||||
} from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function WajsHome() {
|
||||
return <h1>Wajs Home</h1>;
|
||||
const [sending, setSending] = useState(false);
|
||||
const { data: hookData, isLoading: hooksLoading } = useSWR(
|
||||
"/wa-hook/list?limit=5",
|
||||
() => apiFetch["wa-hook"].list.get({ query: { limit: 5 } })
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
number: "",
|
||||
text: "",
|
||||
},
|
||||
validate: {
|
||||
number: (value) => (value.length < 5 ? "Invalid phone number" : null),
|
||||
text: (value) => (value.length < 1 ? "Message cannot be empty" : null),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSendMessage = async (values: typeof form.values) => {
|
||||
setSending(true);
|
||||
try {
|
||||
const { data, error } = await apiFetch.api.wa["send-text"].post(values);
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: "Failed to send",
|
||||
message: (error as any)?.value?.message || "Something went wrong",
|
||||
color: "red",
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: "Message Sent",
|
||||
message: `Successfully sent to ${values.number}`,
|
||||
color: "teal",
|
||||
icon: <IconSend size={16} />,
|
||||
});
|
||||
form.reset();
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: "Error",
|
||||
message: "Network error or server unreachable",
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Incoming Messages",
|
||||
value: hookData?.data && 'count' in hookData.data ? (hookData.data.count as number) * 10 : "...",
|
||||
icon: IconMessage2,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
title: "Active Webhooks",
|
||||
value: "8",
|
||||
icon: IconWebhook,
|
||||
color: "teal",
|
||||
},
|
||||
{
|
||||
title: "API Keys",
|
||||
value: "3",
|
||||
icon: IconKey,
|
||||
color: "violet",
|
||||
},
|
||||
];
|
||||
|
||||
const recentHooks = (hookData?.data && 'list' in hookData.data ? hookData.data.list : []) as any[];
|
||||
|
||||
return (
|
||||
<Stack gap="xl" py="sm">
|
||||
<Box>
|
||||
<Title order={2} fw={900}>
|
||||
Dashboard Overview
|
||||
</Title>
|
||||
<Text c="dimmed" fz="sm">
|
||||
Monitor your WhatsApp integration activity and system health.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{stats.map((stat) => (
|
||||
<Paper key={stat.title} withBorder p="md" radius="md">
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Text c="dimmed" fw={700} size="xs" tt="uppercase">
|
||||
{stat.title}
|
||||
</Text>
|
||||
<Text fw={900} size="xl">
|
||||
{stat.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ThemeIcon
|
||||
color={stat.color}
|
||||
variant="light"
|
||||
size={48}
|
||||
radius="md"
|
||||
>
|
||||
<stat.icon size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon color="orange" variant="light" radius="sm">
|
||||
<IconClock size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Recent Hook Activity</Text>
|
||||
</Group>
|
||||
<Button
|
||||
component={Link}
|
||||
to={clientRoutes["/sq/dashboard/wa-hook"]}
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
>
|
||||
View All
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Divider mb="sm" variant="dotted" />
|
||||
|
||||
<Stack gap="sm">
|
||||
{hooksLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" type="dots" />
|
||||
</Center>
|
||||
) : recentHooks.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm">
|
||||
No recent activity found.
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
recentHooks.map((hook: any) => (
|
||||
<Box
|
||||
key={hook.id}
|
||||
p="xs"
|
||||
style={(theme) => ({
|
||||
borderRadius: theme.radius.sm,
|
||||
transition: "background-color 100ms ease",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--mantine-color-default-hover)",
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Badge size="xs" variant="outline" color="blue">
|
||||
{hook.data?.type || "MESSAGE"}
|
||||
</Badge>
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} lineClamp={1}>
|
||||
{hook.data?.text || "Media message received"}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
From: {hook.data?.number || "Unknown"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{dayjs(hook.createdAt).fromNow()}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon color="green" variant="light" radius="sm">
|
||||
<IconSend size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Send Test Message</Text>
|
||||
</Group>
|
||||
<form onSubmit={form.onSubmit(handleSendMessage)}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Phone Number"
|
||||
placeholder="6281234567890 or 1234567890@lid"
|
||||
required
|
||||
leftSection={<IconPhone size={16} />}
|
||||
{...form.getInputProps("number")}
|
||||
/>
|
||||
<Textarea
|
||||
label="Message Text"
|
||||
placeholder="Type your message here..."
|
||||
required
|
||||
minRows={3}
|
||||
{...form.getInputProps("text")}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={sending}
|
||||
color="teal"
|
||||
leftSection={<IconSend size={18} />}
|
||||
fullWidth
|
||||
>
|
||||
Send Message
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Paper withBorder radius="md" p="md" h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Box>
|
||||
<Group gap="xs" mb="xs">
|
||||
<ThemeIcon color="teal" variant="filled" radius="sm">
|
||||
<IconBolt size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Quick Actions</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed" mb="lg">
|
||||
Commonly used tools and management options.
|
||||
</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
component={Link}
|
||||
to={clientRoutes["/sq/dashboard/webhook"]}
|
||||
color="teal"
|
||||
justify="space-between"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
>
|
||||
Manage Webhooks
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={clientRoutes["/sq/dashboard/apikey/apikey"]}
|
||||
color="teal"
|
||||
justify="space-between"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
>
|
||||
API Keys
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={clientRoutes["/sq/dashboard/wa-hook"]}
|
||||
color="teal"
|
||||
justify="space-between"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
>
|
||||
Activity Logs
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box mt="xl">
|
||||
<Paper p="xs" radius="sm" withBorder>
|
||||
<Group gap="xs">
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
bg="green.6"
|
||||
style={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<Text fz="xs" fw={700}>
|
||||
System Online
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { Badge, Button, Chip, Group, Pill, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Paper,
|
||||
Title,
|
||||
Divider,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Box,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import { modals } from "@mantine/modals";
|
||||
import {
|
||||
IconPlayerPlay,
|
||||
IconRefresh,
|
||||
IconScan,
|
||||
IconCircleCheck,
|
||||
IconAlertCircle,
|
||||
IconSettings,
|
||||
IconDeviceMobile,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
export default function WajsLayout() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -13,59 +35,145 @@ export default function WajsLayout() {
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
refreshInterval: 3000,
|
||||
onSuccess(data, key, config) {
|
||||
console.log(data.data?.state);
|
||||
},
|
||||
});
|
||||
|
||||
if (!data?.data?.state) return <Outlet />;
|
||||
if (data.data?.state.qr)
|
||||
const state = data?.data?.state;
|
||||
|
||||
if (!state) return <Outlet />;
|
||||
|
||||
if (state.qr) {
|
||||
return <Navigate to={clientRoutes["/wajs/qrcode"]} replace />;
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
setLoading(true);
|
||||
await apiFetch.api.wa.start.post();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setLoading(true);
|
||||
await apiFetch.api.wa.restart.post();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRescan = () => {
|
||||
modals.openConfirmModal({
|
||||
title: "Rescan QR Code",
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to rescan the QR code? This will disconnect the
|
||||
current session and require a new login.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: "Rescan Now", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
setLoading(true);
|
||||
await apiFetch.api.wa["force-start"].post();
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group>
|
||||
<Button
|
||||
loading={loading && !data.data?.state.ready}
|
||||
disabled={data.data?.state.ready}
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
apiFetch.api.wa.start.post();
|
||||
}}
|
||||
>
|
||||
{data.data?.state.ready ? "Ready" : "Start"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
apiFetch.api.wa.restart.post();
|
||||
}}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
modals.openConfirmModal({
|
||||
title: "Rescan QR",
|
||||
children: <Text>Are you sure you want to rescan QR?</Text>,
|
||||
confirmProps: { color: "red" },
|
||||
labels: {
|
||||
cancel: "Cancel",
|
||||
confirm: "Rescan QR",
|
||||
},
|
||||
onCancel: () => setLoading(false),
|
||||
onConfirm: () => {
|
||||
apiFetch.api.wa.restart.post();
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Rescan QR
|
||||
</Button>
|
||||
</Group>
|
||||
<Outlet />
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder p="md" radius="md" shadow="sm">
|
||||
<Group justify="space-between">
|
||||
<Group gap="md">
|
||||
<Box
|
||||
p={8}
|
||||
bg="teal.0"
|
||||
style={{ borderRadius: "8px", display: "flex", alignItems: "center" }}
|
||||
>
|
||||
<IconDeviceMobile size={24} color="var(--mantine-color-teal-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Title order={4}>WhatsApp Connection</Title>
|
||||
<Group gap={6}>
|
||||
{state.ready ? (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
leftSection={<IconCircleCheck size={12} />}
|
||||
>
|
||||
Connected & Ready
|
||||
</Badge>
|
||||
) : state.isStarting ? (
|
||||
<Badge
|
||||
color="yellow"
|
||||
variant="light"
|
||||
leftSection={<Loader size={10} color="yellow" />}
|
||||
>
|
||||
Connecting...
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconAlertCircle size={12} />}
|
||||
>
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
{!state.ready && (
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
loading={loading || state.isStarting}
|
||||
onClick={handleStart}
|
||||
color="teal"
|
||||
>
|
||||
Start Service
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Tooltip label="Reconnect the WhatsApp client" withArrow>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={handleRestart}
|
||||
loading={loading}
|
||||
disabled={!state.ready && !state.isStarting}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Reset session and show QR" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<IconScan size={16} />}
|
||||
onClick={handleRescan}
|
||||
loading={loading}
|
||||
>
|
||||
Rescan QR
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Divider orientation="vertical" />
|
||||
|
||||
<Tooltip label="Settings" withArrow>
|
||||
<ActionIcon variant="default" size="lg">
|
||||
<IconSettings size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Box px="xs">
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,310 +1,201 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Select,
|
||||
Divider,
|
||||
Title,
|
||||
Paper,
|
||||
Box,
|
||||
SimpleGrid,
|
||||
rem,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconCode, IconCheck, IconX } from "@tabler/icons-react";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconArrowLeft,
|
||||
IconLink,
|
||||
IconKey,
|
||||
IconWebhook,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
|
||||
// data.from': data.from,
|
||||
// data.fromNumber': data.fromNumber,
|
||||
// data.fromMe': data.fromMe,
|
||||
// data.body': data.body,
|
||||
// data.hasMedia': data.hasMedia,
|
||||
// data.type': data.type,
|
||||
// data.to': data.to,
|
||||
// data.deviceType': data.deviceType,
|
||||
// data.notifyName': data.notifyName,
|
||||
// data.media.data': data.media?.data ?? null,
|
||||
// data.media.mimetype': data.media?.mimetype ?? null,
|
||||
// data.media.filename': data.media?.filename ?? null,
|
||||
// data.media.filesize': data.media?.filesize ?? 0,
|
||||
|
||||
const templateData = `
|
||||
Available variables:
|
||||
{{data.from}}, {{data.fromNumber}}, {{data.fromMe}}, {{data.body}}, {{data.hasMedia}}, {{data.type}}, {{data.to}}, {{data.deviceType}}, {{data.notifyName}}, {{data.media.data}}, {{data.media.mimetype}}, {{data.media.filename}}, {{data.media.filesize}}
|
||||
`;
|
||||
|
||||
export default function WebhookCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [method, setMethod] = useState("POST");
|
||||
const [headers, setHeaders] = useState(
|
||||
JSON.stringify({ "Content-Type": "application/json" }, null, 2),
|
||||
);
|
||||
const [payload, setPayload] = useState("{}");
|
||||
const [apiToken, setApiToken] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [replay, setReplay] = useState(false);
|
||||
const [replayKey, setReplayKey] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const safeJson = (value: string) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
url: "",
|
||||
method: "POST",
|
||||
apiToken: "",
|
||||
headers: JSON.stringify({ "Content-Type": "application/json" }, null, 2),
|
||||
payload: "{}",
|
||||
enabled: true,
|
||||
replay: false,
|
||||
replayKey: "",
|
||||
},
|
||||
validate: {
|
||||
name: (value) => (value.trim().length < 3 ? "Name must be at least 3 characters" : null),
|
||||
url: (value) => (/^https?:\/\/.+/.test(value) ? null : "Invalid webhook URL"),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value || "{}"), null, 2);
|
||||
} catch {
|
||||
return value || "{}";
|
||||
const { data } = await apiFetch.api.webhook.create.post(values);
|
||||
|
||||
if (data?.success) {
|
||||
notifications.show({
|
||||
title: "Success",
|
||||
message: "Webhook created successfully",
|
||||
color: "teal",
|
||||
icon: <IconCheck size={18} />,
|
||||
});
|
||||
navigate(clientRoutes["/sq/dashboard/webhook"]);
|
||||
} else {
|
||||
throw new Error(data?.message || "Failed to create webhook");
|
||||
}
|
||||
} catch (err: any) {
|
||||
notifications.show({
|
||||
title: "Creation Failed",
|
||||
message: err.message,
|
||||
color: "red",
|
||||
icon: <IconX size={18} />,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewCode = useMemo(() => {
|
||||
let headerObj: Record<string, string> = {};
|
||||
try {
|
||||
headerObj = JSON.parse(headers);
|
||||
} catch {}
|
||||
if (apiToken) headerObj["Authorization"] = `Bearer ${apiToken}`;
|
||||
const prettyHeaders = safeJson(JSON.stringify(headerObj));
|
||||
const prettyPayload = safeJson(payload);
|
||||
const includeBody = ["POST", "PUT", "PATCH"].includes(method.toUpperCase());
|
||||
|
||||
return `fetch("${url || "https://example.com/webhook"}", {
|
||||
method: "${method}",
|
||||
headers: ${prettyHeaders},${includeBody ? `\n body: ${prettyPayload},` : ""}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(console.log)
|
||||
.catch(console.error);`;
|
||||
}, [url, method, headers, payload, apiToken]);
|
||||
|
||||
async function onSubmit() {
|
||||
const { data } = await apiFetch.api.webhook.create.post({
|
||||
name,
|
||||
description,
|
||||
apiToken,
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
payload,
|
||||
enabled,
|
||||
replay,
|
||||
replayKey,
|
||||
});
|
||||
|
||||
if (data?.success) {
|
||||
notifications.show({
|
||||
title: "Webhook Created",
|
||||
message: data.message,
|
||||
color: "teal",
|
||||
icon: <IconCheck />,
|
||||
});
|
||||
|
||||
navigate(clientRoutes["/sq/dashboard/webhook"]);
|
||||
} else {
|
||||
notifications.show({
|
||||
title: "Creation Failed",
|
||||
message: data?.message || "Unable to create webhook",
|
||||
color: "red",
|
||||
icon: <IconX />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack style={{ backgroundColor: "#191919" }} p="xl">
|
||||
<Stack
|
||||
gap="md"
|
||||
w={"100%"}
|
||||
mx="auto"
|
||||
bg="rgba(45,45,45,0.6)"
|
||||
p="xl"
|
||||
style={{
|
||||
borderRadius: "20px",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(0,255,200,0.2)",
|
||||
// boxShadow: "0 0 25px rgba(0,255,200,0.15)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Title order={2} c="#EAEAEA" fw={600}>
|
||||
Create Webhook
|
||||
</Title>
|
||||
<IconCode color="#00FFFF" size={28} />
|
||||
</Group>
|
||||
|
||||
<Divider color="rgba(0,255,200,0.2)" />
|
||||
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
placeholder="https://example.com/webhook"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="HTTP Method"
|
||||
placeholder="Select method"
|
||||
value={method}
|
||||
onChange={(v) => setMethod(v || "POST")}
|
||||
data={["POST", "GET", "PUT", "PATCH", "DELETE"].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="API Token"
|
||||
placeholder="Bearer ..."
|
||||
value={apiToken}
|
||||
onChange={(e) => {
|
||||
setApiToken(e.target.value);
|
||||
try {
|
||||
const current = JSON.parse(headers);
|
||||
if (!e.target.value) {
|
||||
delete current["Authorization"];
|
||||
} else {
|
||||
current["Authorization"] = `Bearer ${e.target.value}`;
|
||||
}
|
||||
setHeaders(JSON.stringify(current, null, 2));
|
||||
} catch {}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* <Stack gap="xs">
|
||||
<Text fw={600} c="#EAEAEA">
|
||||
Headers (JSON)
|
||||
</Text>
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
height="20vh"
|
||||
language="json"
|
||||
value={headers}
|
||||
onChange={(val) => setHeaders(val ?? "{}")}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: "off",
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</Stack> */}
|
||||
|
||||
{/* <Stack gap="xs">
|
||||
<Text fw={600} c="#EAEAEA">
|
||||
Payload
|
||||
</Text>
|
||||
<Text size="xs" c="#9A9A9A" mb="xs">
|
||||
{templateData}
|
||||
</Text>
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
height="35vh"
|
||||
language="json"
|
||||
value={payload}
|
||||
onChange={(val) => setPayload(val ?? "{}")}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</Stack> */}
|
||||
|
||||
<Checkbox
|
||||
label="Enable Webhook"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.currentTarget.checked)}
|
||||
color="teal"
|
||||
styles={{
|
||||
label: { color: "#EAEAEA" },
|
||||
}}
|
||||
/>
|
||||
{/* <Checkbox
|
||||
label="Enable Replay"
|
||||
checked={replay}
|
||||
onChange={(e) => setReplay(e.currentTarget.checked)}
|
||||
color="teal"
|
||||
styles={{
|
||||
label: { color: "#EAEAEA" },
|
||||
}}
|
||||
/> */}
|
||||
{/* <TextInput
|
||||
description="Replay Key is used to identify the webhook example: data.text"
|
||||
label="Replay Key"
|
||||
placeholder="Replay Key"
|
||||
value={replayKey}
|
||||
onChange={(e) => setReplayKey(e.target.value)}
|
||||
/> */}
|
||||
|
||||
{/* <Card
|
||||
radius="xl"
|
||||
p="md"
|
||||
style={{
|
||||
background: "rgba(25,25,25,0.6)",
|
||||
border: "1px solid rgba(0,255,200,0.3)",
|
||||
// boxShadow: "0 0 15px rgba(0,255,200,0.15)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} c="#EAEAEA">
|
||||
Request Preview
|
||||
</Text>
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
height="35vh"
|
||||
language="javascript"
|
||||
value={previewCode}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="xl" py="sm">
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconArrowLeft size={14} />}
|
||||
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
|
||||
p={0}
|
||||
>
|
||||
Back to List
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="teal" size="lg">
|
||||
<IconWebhook size={20} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} fw={900}>
|
||||
Create Webhook
|
||||
</Title>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card> */}
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
|
||||
variant="subtle"
|
||||
c="#EAEAEA"
|
||||
styles={{
|
||||
root: { backgroundColor: "#2D2D2D", borderColor: "#00FFC8" },
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #00FFC8, #00FFFF)",
|
||||
color: "#191919",
|
||||
}}
|
||||
>
|
||||
Save Webhook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Divider mt="md" variant="dotted" />
|
||||
</Box>
|
||||
|
||||
<Paper withBorder shadow="sm" p="xl" radius="md">
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||
<TextInput
|
||||
label="Webhook Name"
|
||||
placeholder="e.g. Production Webhook"
|
||||
required
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Select
|
||||
label="HTTP Method"
|
||||
placeholder="Select method"
|
||||
required
|
||||
data={["GET", "POST", "PUT", "PATCH", "DELETE"]}
|
||||
{...form.getInputProps("method")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Endpoint URL"
|
||||
placeholder="https://your-api.com/webhook"
|
||||
required
|
||||
leftSection={<IconLink size={16} />}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="What is this webhook for?"
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="API Token (Optional)"
|
||||
placeholder="Bearer token or custom key"
|
||||
leftSection={<IconKey size={16} />}
|
||||
{...form.getInputProps("apiToken")}
|
||||
/>
|
||||
|
||||
<Box
|
||||
p="md"
|
||||
bg="var(--mantine-color-dark-8)"
|
||||
style={{
|
||||
borderRadius: rem(8),
|
||||
border: "1px solid var(--mantine-color-dark-4)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="sm">
|
||||
Enable Webhook
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Activate this webhook to start receiving events immediately.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Checkbox
|
||||
size="md"
|
||||
color="teal"
|
||||
{...form.getInputProps("enabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group justify="right" mt="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={18} />}
|
||||
>
|
||||
Create Webhook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,330 +9,273 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title
|
||||
Title,
|
||||
Paper,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Container,
|
||||
Box,
|
||||
Loader,
|
||||
Center,
|
||||
SimpleGrid,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconCheck, IconCode, IconX } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconX,
|
||||
IconTrash,
|
||||
IconArrowLeft,
|
||||
IconLink,
|
||||
IconKey,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import type { WebHook } from "generated/prisma";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default function WebhookEdit() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const id = searchParams.get("id");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
"/",
|
||||
() =>
|
||||
apiFetch.api.webhook
|
||||
.find({
|
||||
id: id!,
|
||||
})
|
||||
.get(),
|
||||
{ dedupingInterval: 3000 },
|
||||
id ? `/webhook/${id}` : null,
|
||||
() => apiFetch.api.webhook.find({ id: id! }).get(),
|
||||
{ dedupingInterval: 3000 }
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useShallowEffect(() => {
|
||||
mutate();
|
||||
}, [data]);
|
||||
const handleDelete = () => {
|
||||
modals.openConfirmModal({
|
||||
title: "Remove Webhook",
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to remove this webhook? This action cannot be undone.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: "Delete Webhook", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await apiFetch.api.webhook.remove({ id: id! }).delete();
|
||||
notifications.show({
|
||||
title: "Deleted",
|
||||
message: "Webhook has been removed",
|
||||
color: "red",
|
||||
});
|
||||
navigate(clientRoutes["/sq/dashboard/webhook"]);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: "Error",
|
||||
message: "Failed to delete webhook",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
if (!data?.data?.webhook) return <div>No data</div>;
|
||||
if (isLoading)
|
||||
return (
|
||||
<Center py={100}>
|
||||
<Loader color="teal" size="lg" type="dots" />
|
||||
</Center>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Edit Webhook</Title>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: "Remove Webhook",
|
||||
children: (
|
||||
<Text>Are you sure you want to remove this webhook?</Text>
|
||||
),
|
||||
confirmProps: { color: "red" },
|
||||
labels: {
|
||||
cancel: "Cancel",
|
||||
confirm: "Remove",
|
||||
},
|
||||
onConfirm: () => {
|
||||
apiFetch.api.webhook
|
||||
.remove({
|
||||
id: id!,
|
||||
})
|
||||
.delete();
|
||||
navigate(clientRoutes["/sq/dashboard/webhook"]);
|
||||
},
|
||||
onCancel: () => {
|
||||
navigate(
|
||||
clientRoutes["/sq/dashboard/webhook/webhook-edit"] +
|
||||
"?id=" +
|
||||
id,
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
<EditView webhook={data.data?.webhook || null} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function EditView({ webhook }: { webhook: Partial<WebHook> | null }) {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState(webhook?.name || "");
|
||||
const [description, setDescription] = useState(webhook?.description || "");
|
||||
const [url, setUrl] = useState(webhook?.url || "");
|
||||
const [method, setMethod] = useState(webhook?.method || "POST");
|
||||
const [headers, setHeaders] = useState(webhook?.headers || "{}");
|
||||
|
||||
const [apiToken, setApiToken] = useState(webhook?.apiToken || "");
|
||||
const [enabled, setEnabled] = useState(webhook?.enabled );
|
||||
|
||||
|
||||
async function onSubmit() {
|
||||
if (!webhook?.id) {
|
||||
return notifications.show({
|
||||
title: "Webhook ID Not Found",
|
||||
message: "Unable to update webhook",
|
||||
color: "red",
|
||||
icon: <IconX />,
|
||||
});
|
||||
}
|
||||
const { data } = await apiFetch.api.webhook
|
||||
.update({
|
||||
id: webhook?.id,
|
||||
})
|
||||
.put({
|
||||
name,
|
||||
description,
|
||||
apiToken,
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
enabled: enabled || false,
|
||||
});
|
||||
|
||||
if (data?.success) {
|
||||
notifications.show({
|
||||
title: "Webhook Created",
|
||||
message: data.message,
|
||||
color: "teal",
|
||||
icon: <IconCheck />,
|
||||
});
|
||||
navigate(clientRoutes["/sq/dashboard/webhook"]);
|
||||
} else {
|
||||
notifications.show({
|
||||
title: "Creation Failed",
|
||||
message: data?.message || "Unable to create webhook",
|
||||
color: "red",
|
||||
icon: <IconX />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack style={{ backgroundColor: "#191919" }} p="xl" >
|
||||
<Stack
|
||||
gap="md"
|
||||
w={"100%"}
|
||||
mx="auto"
|
||||
bg={enabled? "" : "rgba(47, 34, 34, 0.6)"}
|
||||
p="xl"
|
||||
style={{
|
||||
borderRadius: "20px",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(0,255,200,0.2)",
|
||||
// boxShadow: "0 0 25px rgba(0,255,200,0.15)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Title order={2} c="#EAEAEA" fw={600}>
|
||||
Edit Webhook
|
||||
</Title>
|
||||
<IconCode color="#00FFFF" size={28} />
|
||||
</Group>
|
||||
|
||||
<Divider color="rgba(0,255,200,0.2)" />
|
||||
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
placeholder="https://example.com/webhook"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="HTTP Method"
|
||||
placeholder="Select method"
|
||||
value={method}
|
||||
onChange={(v) => setMethod(v || "POST")}
|
||||
data={["POST", "GET", "PUT", "PATCH", "DELETE"].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="API Token"
|
||||
placeholder="Bearer ..."
|
||||
value={apiToken}
|
||||
onChange={(e) => {
|
||||
setApiToken(e.target.value);
|
||||
try {
|
||||
const current = JSON.parse(headers);
|
||||
if (!e.target.value) {
|
||||
delete current["Authorization"];
|
||||
} else {
|
||||
current["Authorization"] = `Bearer ${e.target.value}`;
|
||||
}
|
||||
setHeaders(JSON.stringify(current, null, 2));
|
||||
} catch {}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* <Stack gap="xs">
|
||||
<Text fw={600} c="#EAEAEA">
|
||||
Headers (JSON)
|
||||
</Text>
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
height="20vh"
|
||||
language="json"
|
||||
value={headers}
|
||||
onChange={(val) => setHeaders(val ?? "{}")}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: "off",
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</Stack> */}
|
||||
|
||||
{/* <Stack gap="xs">
|
||||
<Text fw={600} c="#EAEAEA">
|
||||
Payload
|
||||
</Text>
|
||||
<Text size="xs" c="#9A9A9A" mb="xs">
|
||||
{templateData}
|
||||
</Text>
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
height="35vh"
|
||||
language="json"
|
||||
value={payload}
|
||||
onChange={(val) => setPayload(val ?? "{}")}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</Stack> */}
|
||||
<Checkbox
|
||||
label="Enable Webhook"
|
||||
defaultChecked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked as any)}
|
||||
color="teal"
|
||||
styles={{
|
||||
label: { color: "#EAEAEA" },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* <Checkbox
|
||||
label="Enable Replay"
|
||||
checked={replay}
|
||||
onChange={(e) => setReplay(e.target.checked as any)}
|
||||
color="teal"
|
||||
styles={{
|
||||
label: { color: "#EAEAEA" },
|
||||
}}
|
||||
/> */}
|
||||
|
||||
{/* <TextInput
|
||||
description="Replay Key is used to identify the webhook example: data.text"
|
||||
label="Replay Key"
|
||||
placeholder="Replay Key"
|
||||
value={replayKey}
|
||||
onChange={(e) => setReplayKey(e.target.value)}
|
||||
/> */}
|
||||
|
||||
{/* <Card
|
||||
radius="xl"
|
||||
p="md"
|
||||
style={{
|
||||
background: "rgba(25,25,25,0.6)",
|
||||
border: "1px solid rgba(0,255,200,0.3)",
|
||||
// boxShadow: "0 0 15px rgba(0,255,200,0.15)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} c="#EAEAEA">
|
||||
Request Preview
|
||||
</Text>
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
height="35vh"
|
||||
language="javascript"
|
||||
value={previewCode}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
if (error || !data?.data?.webhook)
|
||||
return (
|
||||
<Center py={100}>
|
||||
<Paper withBorder p="xl" radius="md">
|
||||
<Stack align="center">
|
||||
<IconX size={48} color="red" />
|
||||
<Text fw={600}>Webhook not found or error loading data</Text>
|
||||
<Button variant="light" onClick={() => navigate(-1)}>
|
||||
Go Back
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card> */}
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
return (
|
||||
<Stack gap="xl" py="sm">
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconArrowLeft size={14} />}
|
||||
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
|
||||
p={0}
|
||||
>
|
||||
Back to List
|
||||
</Button>
|
||||
</Group>
|
||||
<Title order={2} fw={900}>
|
||||
Edit Webhook
|
||||
</Title>
|
||||
</Stack>
|
||||
<Button
|
||||
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
|
||||
variant="subtle"
|
||||
c="#EAEAEA"
|
||||
styles={{
|
||||
root: { backgroundColor: "#2D2D2D", borderColor: "#00FFC8" },
|
||||
}}
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={18} />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #00FFC8, #00FFFF)",
|
||||
color: "#191919",
|
||||
}}
|
||||
>
|
||||
Save Webhook
|
||||
Remove Webhook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Divider mt="md" variant="dotted" />
|
||||
</Box>
|
||||
|
||||
<EditView webhook={data.data.webhook} onUpdated={() => mutate()} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function EditView({ webhook, onUpdated }: { webhook: Partial<WebHook>; onUpdated: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: webhook.name || "",
|
||||
description: webhook.description || "",
|
||||
url: webhook.url || "",
|
||||
method: webhook.method || "POST",
|
||||
apiToken: webhook.apiToken || "",
|
||||
enabled: webhook.enabled ?? true,
|
||||
},
|
||||
validate: {
|
||||
name: (value) => (value.trim().length < 3 ? "Name must be at least 3 characters" : null),
|
||||
url: (value) => (/^https?:\/\/.+/.test(value) ? null : "Invalid webhook URL"),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await apiFetch.api.webhook
|
||||
.update({ id: webhook.id! })
|
||||
.put({
|
||||
...values,
|
||||
headers: webhook.headers || "{}", // Maintain headers if any
|
||||
});
|
||||
|
||||
if (data?.success) {
|
||||
notifications.show({
|
||||
title: "Success",
|
||||
message: "Webhook updated successfully",
|
||||
color: "teal",
|
||||
icon: <IconCheck size={18} />,
|
||||
});
|
||||
onUpdated();
|
||||
navigate(clientRoutes["/sq/dashboard/webhook"]);
|
||||
} else {
|
||||
throw new Error(data?.message || "Failed to update");
|
||||
}
|
||||
} catch (err: any) {
|
||||
notifications.show({
|
||||
title: "Update Failed",
|
||||
message: err.message,
|
||||
color: "red",
|
||||
icon: <IconX size={18} />,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" p="xl" radius="md">
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||
<TextInput
|
||||
label="Webhook Name"
|
||||
placeholder="e.g. My Custom Integration"
|
||||
required
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Select
|
||||
label="HTTP Method"
|
||||
placeholder="Select method"
|
||||
required
|
||||
data={["GET", "POST", "PUT", "PATCH", "DELETE"]}
|
||||
{...form.getInputProps("method")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Endpoint URL"
|
||||
placeholder="https://your-api.com/webhook"
|
||||
required
|
||||
leftSection={<IconLink size={16} />}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="What is this webhook for?"
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="API Token (Optional)"
|
||||
placeholder="Bearer token or custom key"
|
||||
leftSection={<IconKey size={16} />}
|
||||
{...form.getInputProps("apiToken")}
|
||||
/>
|
||||
|
||||
<Box
|
||||
p="md"
|
||||
bg="var(--mantine-color-dark-8)"
|
||||
style={{ borderRadius: rem(8), border: "1px solid var(--mantine-color-dark-4)" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="sm">
|
||||
Enable Webhook
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
When disabled, the system will stop sending events to this endpoint.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Checkbox
|
||||
size="md"
|
||||
color="teal"
|
||||
{...form.getInputProps("enabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group justify="right" mt="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={18} />}
|
||||
>
|
||||
Update Webhook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
Stack,
|
||||
Divider,
|
||||
Button,
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Paper,
|
||||
rem,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconLink,
|
||||
@@ -23,6 +28,9 @@ import {
|
||||
IconEdit,
|
||||
IconPlus,
|
||||
IconMessageReply,
|
||||
IconWebhook,
|
||||
IconWorld,
|
||||
IconExternalLink,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import useSWR from "swr";
|
||||
@@ -34,7 +42,7 @@ import { useShallowEffect } from "@mantine/hooks";
|
||||
export default function WebhookHome() {
|
||||
const navigate = useNavigate();
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
"/",
|
||||
"/webhook-list",
|
||||
apiFetch.api.webhook.list.get,
|
||||
{ dedupingInterval: 3000, refreshInterval: 3000 },
|
||||
);
|
||||
@@ -45,216 +53,209 @@ export default function WebhookHome() {
|
||||
mutate();
|
||||
}, []);
|
||||
|
||||
function ButtonCreate() {
|
||||
return (
|
||||
<Tooltip label="Create new webhook" withArrow color="teal">
|
||||
<Button
|
||||
radius="xl"
|
||||
size="md"
|
||||
leftSection={<IconPlus size={18} />}
|
||||
variant="gradient"
|
||||
gradient={{ from: "#00FFC8", to: "#00FFFF", deg: 135 }}
|
||||
style={{
|
||||
color: "#191919",
|
||||
fontWeight: 600,
|
||||
// boxShadow: "0 0 12px rgba(0,255,200,0.25)",
|
||||
transition: "transform 0.2s ease, box-shadow 0.2s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(-2px)";
|
||||
e.currentTarget.style.boxShadow = "0 0 20px rgba(0,255,200,0.4)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow = "0 0 12px rgba(0,255,200,0.25)";
|
||||
}}
|
||||
onClick={() => navigate("/sq/dashboard/webhook/webhook-create")}
|
||||
>
|
||||
Create Webhook
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const handleRefresh = () => {
|
||||
mutate();
|
||||
notifications.show({
|
||||
title: "Refreshing Data",
|
||||
message: "Webhook list has been updated.",
|
||||
color: "teal",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<Center h="100vh" bg="#191919">
|
||||
<Loader color="teal" size="lg" />
|
||||
<Center py={100}>
|
||||
<Stack align="center" gap="md">
|
||||
<Loader color="teal" size="lg" type="dots" />
|
||||
<Text c="dimmed" fz="sm">Loading your webhooks...</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<Center h="100vh" bg="#191919">
|
||||
<Text c="#FF4B4B" fw={500}>
|
||||
Failed to load webhooks. Please try again.
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
|
||||
if (!webhooks.length)
|
||||
return (
|
||||
<Center h="100vh" bg="#191919">
|
||||
<Stack align="center" gap="sm">
|
||||
<Text c="#9A9A9A" size="lg">
|
||||
No webhooks found
|
||||
</Text>
|
||||
<Text c="#00FFC8" size="sm">
|
||||
Connect your first webhook to start managing events
|
||||
</Text>
|
||||
<ButtonCreate />
|
||||
</Stack>
|
||||
<Center py={100}>
|
||||
<Paper withBorder p="xl" radius="md" bg="var(--mantine-color-dark-8)">
|
||||
<Stack align="center" gap="sm">
|
||||
<IconX size={48} color="var(--mantine-color-red-6)" />
|
||||
<Text fw={600}>Failed to load webhooks</Text>
|
||||
<Button variant="light" color="red" onClick={() => mutate()}>
|
||||
Try Again
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack style={{ backgroundColor: "#191919" }} p="xl">
|
||||
<Title order={2} c="#EAEAEA" fw={600}>
|
||||
Webhook Manager
|
||||
</Title>
|
||||
<Group justify="end" mb="lg">
|
||||
|
||||
<ButtonCreate />
|
||||
<Tooltip label="Refresh webhooks" withArrow color="cyan">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
onClick={() => {
|
||||
mutate();
|
||||
notifications.show({
|
||||
title: "Refreshing data",
|
||||
message: "Webhook list is being updated...",
|
||||
color: "teal",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconRefresh color="#00FFFF" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Stack gap="md">
|
||||
{webhooks.map((webhook) => (
|
||||
<Card
|
||||
key={webhook.id}
|
||||
p="lg"
|
||||
radius="xl"
|
||||
style={{
|
||||
background: "rgba(45,45,45,0.6)",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(0,255,200,0.2)",
|
||||
// boxShadow: "0 0 12px rgba(0,255,200,0.15)",
|
||||
transition: "transform 0.2s ease, box-shadow 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="end" mb="sm">
|
||||
<Group>
|
||||
<IconLink color="#00FFFF" />
|
||||
<Text c="#EAEAEA" fw={500} size="lg">
|
||||
{webhook.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<ActionIcon
|
||||
c={"teal"}
|
||||
variant="light"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${clientRoutes["/sq/dashboard/webhook/webhook-edit"]}?id=${webhook.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconEdit />
|
||||
</ActionIcon>
|
||||
<Stack gap="xl" py="sm">
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<IconWebhook size={32} color="var(--mantine-color-teal-filled)" />
|
||||
<Title order={2} fw={900}>
|
||||
Webhook Manager
|
||||
</Title>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
Configure external endpoints to receive real-time event notifications.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Refresh" withArrow>
|
||||
<ActionIcon variant="default" size="lg" onClick={handleRefresh}>
|
||||
<IconRefresh size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="teal"
|
||||
onClick={() => navigate("/sq/dashboard/webhook/webhook-create")}
|
||||
>
|
||||
Add Webhook
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider mt="md" variant="dotted" />
|
||||
</Box>
|
||||
|
||||
<Stack gap={"md"}>
|
||||
<Group>
|
||||
<Badge
|
||||
color={webhook.enabled ? "teal" : "red"}
|
||||
radius="xl"
|
||||
leftSection={
|
||||
webhook.enabled ? (
|
||||
<IconCheck size={14} />
|
||||
) : (
|
||||
<IconX size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{webhook.enabled ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
<Badge
|
||||
bg={"teal"}
|
||||
leftSection={<IconMessageReply size={16} color="#00FFC8" />}
|
||||
>
|
||||
{webhook.replay ? "Replay" : "Not Replay"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text c="#9A9A9A" size="sm">
|
||||
{webhook.description}
|
||||
{!webhooks.length ? (
|
||||
<Center py={80}>
|
||||
<Stack align="center" gap="xl">
|
||||
<Box style={{ textAlign: "center" }}>
|
||||
<ThemeIcon size={80} radius="xl" color="gray" variant="light" mb="md">
|
||||
<IconWorld size={40} />
|
||||
</ThemeIcon>
|
||||
<Title order={3}>No Webhooks Configured</Title>
|
||||
<Text c="dimmed" mt="xs">
|
||||
Start by adding your first endpoint to receive WhatsApp events.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Divider color="rgba(0,255,200,0.2)" my="sm" />
|
||||
</Box>
|
||||
<Button
|
||||
size="lg"
|
||||
color="teal"
|
||||
leftSection={<IconPlus size={20} />}
|
||||
onClick={() => navigate("/sq/dashboard/webhook/webhook-create")}
|
||||
>
|
||||
Create Your First Webhook
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
{webhooks.map((webhook) => (
|
||||
<Card
|
||||
key={webhook.id}
|
||||
p="xl"
|
||||
radius="md"
|
||||
withBorder
|
||||
className="webhook-card"
|
||||
style={{
|
||||
transition: "transform 0.2s ease, box-shadow 0.2s ease",
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" mb="lg">
|
||||
<Stack gap={4}>
|
||||
<Text fw={700} fz="lg" lineClamp={1}>
|
||||
{webhook.name || "Unnamed Webhook"}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge
|
||||
color={webhook.enabled ? "teal" : "red"}
|
||||
variant="dot"
|
||||
size="sm"
|
||||
>
|
||||
{webhook.enabled ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
{webhook.replay && (
|
||||
<Badge variant="light" color="blue" size="sm" leftSection={<IconMessageReply size={12} />}>
|
||||
Replay
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<IconCode size={16} color="#00FFC8" />
|
||||
<Text c="#9A9A9A" size="sm">
|
||||
Method:
|
||||
</Text>
|
||||
<Text c="#EAEAEA" size="sm" fw={500}>
|
||||
{webhook.method}
|
||||
</Text>
|
||||
<Tooltip label="Edit Settings" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${clientRoutes["/sq/dashboard/webhook/webhook-edit"]}?id=${webhook.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<IconLink size={16} color="#00FFC8" />
|
||||
<Text c="#9A9A9A" size="sm">
|
||||
URL:
|
||||
</Text>
|
||||
<Text c="#EAEAEA" size="sm" fw={500}>
|
||||
{webhook.url}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text c="dimmed" fz="sm" lineClamp={2} mb="xl" h={rem(40)}>
|
||||
{webhook.description || "No description provided for this webhook."}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs">
|
||||
<IconKey size={16} color="#00FFC8" />
|
||||
<Text c="#9A9A9A" size="sm">
|
||||
API Token:
|
||||
</Text>
|
||||
<Text c="#EAEAEA" size="sm" fw={500}>
|
||||
{webhook.apiToken?.slice(0, 6) + "..." || "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider mb="xl" variant="dashed" />
|
||||
|
||||
{/* <Group gap="xs">
|
||||
<Text c="#9A9A9A" size="sm">
|
||||
Headers:
|
||||
</Text>
|
||||
<Text c="#EAEAEA" size="sm" fw={500}>
|
||||
{Object.keys(webhook.headers || {}).length
|
||||
? webhook.headers
|
||||
: "No headers configured"}
|
||||
</Text>
|
||||
</Group> */}
|
||||
|
||||
{/* <Group gap="xs">
|
||||
<Text c="#9A9A9A" size="sm">
|
||||
Payload:
|
||||
</Text>
|
||||
<Text c="#EAEAEA" size="sm" fw={500}>
|
||||
{Object.keys(webhook.payload || {}).length
|
||||
? webhook.payload
|
||||
: "Empty payload"}
|
||||
</Text>
|
||||
</Group> */}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack gap="sm">
|
||||
<DetailRow
|
||||
icon={IconCode}
|
||||
label="Method"
|
||||
value={webhook.method}
|
||||
color="blue"
|
||||
/>
|
||||
<DetailRow
|
||||
icon={IconLink}
|
||||
label="Endpoint"
|
||||
value={webhook.url}
|
||||
color="teal"
|
||||
isLink
|
||||
/>
|
||||
<DetailRow
|
||||
icon={IconKey}
|
||||
label="Token"
|
||||
value={webhook.apiToken ? `${webhook.apiToken.slice(0, 12)}...` : "None"}
|
||||
color="violet"
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ icon: Icon, label, value, color, isLink }: any) {
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon variant="light" color={color} size="sm" radius="sm">
|
||||
<Icon size={14} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text fz="xs" c="dimmed" fw={500} tt="uppercase">
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={600}
|
||||
component={isLink ? "a" : "div"}
|
||||
href={isLink ? value : undefined}
|
||||
target={isLink ? "_blank" : undefined}
|
||||
style={{
|
||||
wordBreak: "break-all",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: rem(4),
|
||||
color: isLink ? "var(--mantine-color-teal-filled)" : "inherit",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
{isLink && <IconExternalLink size={12} />}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Container,
|
||||
Paper,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useNavigate, Outlet } from "react-router-dom";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export default function WebhookLayout() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,140 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { ReactQRCode } from "@lglab/react-qr-code";
|
||||
import { Card, Container, Group } from "@mantine/core";
|
||||
import {
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
Paper,
|
||||
Box,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
List,
|
||||
Center,
|
||||
Loader,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandWhatsapp,
|
||||
IconDeviceMobile,
|
||||
IconSettings,
|
||||
IconQrcode,
|
||||
IconArrowLeft,
|
||||
IconCircleCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import useSWR from "swr";
|
||||
import { useNavigate, Navigate } from "react-router-dom";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
|
||||
export default function QrcodePage() {
|
||||
const { data } = useSWR("/wa/qr", apiFetch.api.wa.qr.get, {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useSWR("/wa/qr", apiFetch.api.wa.qr.get, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
refreshInterval: 3000,
|
||||
});
|
||||
|
||||
const { data: stateData } = useSWR("/wa/state", apiFetch.api.wa.state.get, {
|
||||
refreshInterval: 3000,
|
||||
});
|
||||
|
||||
// Redirect to dashboard if already connected
|
||||
if (stateData?.data?.state?.ready) {
|
||||
return <Navigate to={clientRoutes["/sq/dashboard/wajs/wajs-home"]} replace />;
|
||||
}
|
||||
|
||||
const qrValue = data?.data?.qr;
|
||||
|
||||
return (
|
||||
<Container size={"sm"}>
|
||||
<h1>QrCode</h1>
|
||||
<Group>
|
||||
<Card bg={"white"}>
|
||||
<ReactQRCode size={256} value={data?.data?.qr || ""} />
|
||||
</Card>
|
||||
</Group>
|
||||
</Container>
|
||||
<Box
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--mantine-color-dark-9)",
|
||||
}}
|
||||
>
|
||||
<Container size={500}>
|
||||
<Stack gap="xl">
|
||||
<Center>
|
||||
<Stack align="center" gap="xs">
|
||||
<ThemeIcon size={60} radius="xl" color="green" variant="light">
|
||||
<IconBrandWhatsapp size={40} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} fw={900}>
|
||||
Link WhatsApp Device
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
Scan the QR code below to connect your WhatsApp account to the server.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
<Paper withBorder shadow="xl" p={40} radius="lg">
|
||||
<Stack gap="xl" align="center">
|
||||
<Box
|
||||
p="md"
|
||||
bg="white"
|
||||
style={{
|
||||
borderRadius: rem(12),
|
||||
boxShadow: "0 0 20px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
{qrValue ? (
|
||||
<ReactQRCode size={256} value={qrValue} />
|
||||
) : (
|
||||
<Center w={256} h={256}>
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader color="green" size="md" type="dots" />
|
||||
<Text size="xs" c="dark" fw={600}>
|
||||
Generating QR Code...
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box w="100%">
|
||||
<Text fw={700} size="sm" mb="md">
|
||||
How to connect:
|
||||
</Text>
|
||||
<List
|
||||
spacing="sm"
|
||||
size="sm"
|
||||
center
|
||||
icon={
|
||||
<ThemeIcon color="green" size={20} radius="xl">
|
||||
<IconCircleCheck size={12} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<List.Item>Open WhatsApp on your phone</List.Item>
|
||||
<List.Item>
|
||||
Tap <Text span fw={700}>Menu</Text> or <Text span fw={700}>Settings</Text> and select <Text span fw={700}>Linked Devices</Text>
|
||||
</List.Item>
|
||||
<List.Item>Tap on <Text span fw={700}>Link a Device</Text></List.Item>
|
||||
<List.Item>Point your phone to this screen to capture the code</List.Item>
|
||||
</List>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Center>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => navigate(clientRoutes["/sq/dashboard/wajs/wajs-home"])}
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</Center>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,239 +8,239 @@ import { prisma } from '../prisma';
|
||||
|
||||
|
||||
type HookData =
|
||||
| { eventType: "qr"; qr: string }
|
||||
| { eventType: "start" }
|
||||
| { eventType: "ready" }
|
||||
| { eventType: "disconnected"; reason?: string }
|
||||
| { eventType: "reconnect" }
|
||||
| { eventType: "auth_failure"; msg: string }
|
||||
| { eventType: "message" } & Partial<WAWebJS.Message>;
|
||||
| { eventType: "qr"; qr: string }
|
||||
| { eventType: "start" }
|
||||
| { eventType: "ready" }
|
||||
| { eventType: "disconnected"; reason?: string }
|
||||
| { eventType: "reconnect" }
|
||||
| { eventType: "auth_failure"; msg: string }
|
||||
| { eventType: "message" } & Partial<WAWebJS.Message>;
|
||||
|
||||
|
||||
async function handleHook(data: HookData) {
|
||||
const webHooks = await prisma.webHook.findMany({ where: { enabled: true } });
|
||||
if (webHooks.length === 0) return;
|
||||
await Promise.allSettled(
|
||||
webHooks.map(async (hook) => {
|
||||
try {
|
||||
log(`🌐 Mengirim webhook ke ${hook.name} ${hook.url}`);
|
||||
const webHooks = await prisma.webHook.findMany({ where: { enabled: true } });
|
||||
if (webHooks.length === 0) return;
|
||||
await Promise.allSettled(
|
||||
webHooks.map(async (hook) => {
|
||||
try {
|
||||
log(`🌐 Mengirim webhook ke ${hook.name} ${hook.url}`);
|
||||
|
||||
let res: Response = {} as Response;
|
||||
res = await fetch(hook.url, {
|
||||
method: hook.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${hook.apiToken}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
let res: Response = {} as Response;
|
||||
res = await fetch(hook.url, {
|
||||
method: hook.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${hook.apiToken}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const json = await res.text();
|
||||
logger.info(`[RESPONSE] ${hook.name} ${hook.url}: ${json}`);
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`[ERROR] ${hook.name} ${hook.url}:`);
|
||||
logger.error(`[ERROR] ${hook.name}: ${err}`);
|
||||
}
|
||||
})
|
||||
)
|
||||
const json = await res.text();
|
||||
logger.info(`[RESPONSE] ${hook.name} ${hook.url}: ${json}`);
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`[ERROR] ${hook.name} ${hook.url}:`);
|
||||
logger.error(`[ERROR] ${hook.name}: ${err}`);
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
// === STATE GLOBAL ===
|
||||
const state = {
|
||||
client: null as Client | null,
|
||||
reconnectTimeout: null as NodeJS.Timeout | null,
|
||||
isReconnecting: false,
|
||||
isStarting: false,
|
||||
qr: null as string | null,
|
||||
ready: false,
|
||||
async restart() {
|
||||
log('🔄 Restart manual diminta...');
|
||||
await destroyClient();
|
||||
await startClient();
|
||||
},
|
||||
client: null as Client | null,
|
||||
reconnectTimeout: null as NodeJS.Timeout | null,
|
||||
isReconnecting: false,
|
||||
isStarting: false,
|
||||
qr: null as string | null,
|
||||
ready: false,
|
||||
async restart() {
|
||||
log('🔄 Restart manual diminta...');
|
||||
await destroyClient();
|
||||
await startClient();
|
||||
},
|
||||
|
||||
async forceStart() {
|
||||
log('⚠️ Force start — menghapus cache dan session auth...');
|
||||
await destroyClient();
|
||||
await safeRm("./.wwebjs_auth");
|
||||
await safeRm("./wwebjs_cache");
|
||||
await startClient();
|
||||
},
|
||||
async stop() {
|
||||
log('🛑 Stop manual diminta...');
|
||||
await destroyClient();
|
||||
},
|
||||
async forceStart() {
|
||||
log('⚠️ Force start — menghapus cache dan session auth...');
|
||||
await destroyClient();
|
||||
await safeRm("./.wwebjs_auth");
|
||||
await safeRm("./wwebjs_cache");
|
||||
await startClient();
|
||||
},
|
||||
async stop() {
|
||||
log('🛑 Stop manual diminta...');
|
||||
await destroyClient();
|
||||
},
|
||||
};
|
||||
|
||||
// === UTIL ===
|
||||
function log(...args: any[]) {
|
||||
console.log(`[${new Date().toISOString()}]`, ...args);
|
||||
console.log(`[${new Date().toISOString()}]`, ...args);
|
||||
}
|
||||
|
||||
|
||||
async function safeRm(path: string) {
|
||||
try {
|
||||
await fs.rm(path, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
log(`⚠️ Gagal hapus ${path}:`, err);
|
||||
}
|
||||
try {
|
||||
await fs.rm(path, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
log(`⚠️ Gagal hapus ${path}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// === CLEANUP CLIENT ===
|
||||
async function destroyClient() {
|
||||
if (state.reconnectTimeout) {
|
||||
clearTimeout(state.reconnectTimeout);
|
||||
state.reconnectTimeout = null;
|
||||
}
|
||||
if (state.client) {
|
||||
try {
|
||||
state.client.removeAllListeners();
|
||||
await state.client.destroy();
|
||||
log('🧹 Client lama dihentikan & listener dibersihkan');
|
||||
} catch (err) {
|
||||
log('⚠️ Gagal destroy client:', err);
|
||||
}
|
||||
state.client = null;
|
||||
state.ready = false;
|
||||
if (state.reconnectTimeout) {
|
||||
clearTimeout(state.reconnectTimeout);
|
||||
state.reconnectTimeout = null;
|
||||
}
|
||||
if (state.client) {
|
||||
try {
|
||||
state.client.removeAllListeners();
|
||||
await state.client.destroy();
|
||||
log('🧹 Client lama dihentikan & listener dibersihkan');
|
||||
} catch (err) {
|
||||
log('⚠️ Gagal destroy client:', err);
|
||||
}
|
||||
state.client = null;
|
||||
state.ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
let connectedAt: number | null = null;
|
||||
|
||||
// === PEMBUATAN CLIENT ===
|
||||
async function startClient() {
|
||||
if (state.isStarting || state.isReconnecting) {
|
||||
log('⏳ startClient diabaikan — proses sedang berjalan...');
|
||||
return;
|
||||
if (state.isStarting || state.isReconnecting) {
|
||||
log('⏳ startClient diabaikan — proses sedang berjalan...');
|
||||
return;
|
||||
}
|
||||
state.isStarting = true;
|
||||
|
||||
await destroyClient();
|
||||
|
||||
log('🚀 Memulai WhatsApp client...');
|
||||
handleHook({ eventType: "start" });
|
||||
|
||||
const client = new Client({
|
||||
authStrategy: new LocalAuth({
|
||||
dataPath: process.env.WWEBJS_AUTH || path.join(process.cwd(), '.wwebjs_auth')
|
||||
}),
|
||||
puppeteer: {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
],
|
||||
},
|
||||
webVersionCache: {
|
||||
path: process.env.WWEBJS_CACHE || path.join(process.cwd(), '.wwebjs_cache'),
|
||||
type: 'local',
|
||||
}
|
||||
state.isStarting = true;
|
||||
});
|
||||
|
||||
await destroyClient();
|
||||
state.client = client;
|
||||
|
||||
log('🚀 Memulai WhatsApp client...');
|
||||
handleHook({ eventType: "start" });
|
||||
// === EVENT LISTENERS ===
|
||||
client.on('qr', (qr) => {
|
||||
state.qr = qr;
|
||||
qrcode.generate(qr, { small: true });
|
||||
log('🔑 QR code baru diterbitkan');
|
||||
handleHook({ eventType: "qr", qr });
|
||||
});
|
||||
|
||||
const client = new Client({
|
||||
authStrategy: new LocalAuth({
|
||||
dataPath: process.env.WWEBJS_AUTH || path.join(process.cwd(), '.wwebjs_auth')
|
||||
}),
|
||||
puppeteer: {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
],
|
||||
},
|
||||
webVersionCache: {
|
||||
path: process.env.WWEBJS_CACHE || path.join(process.cwd(), '.wwebjs_cache'),
|
||||
type: 'local',
|
||||
}
|
||||
});
|
||||
|
||||
state.client = client;
|
||||
|
||||
// === EVENT LISTENERS ===
|
||||
client.on('qr', (qr) => {
|
||||
state.qr = qr;
|
||||
qrcode.generate(qr, { small: true });
|
||||
log('🔑 QR code baru diterbitkan');
|
||||
handleHook({ eventType: "qr", qr });
|
||||
});
|
||||
|
||||
client.on('ready', () => {
|
||||
connectedAt = Date.now();
|
||||
log('✅ WhatsApp client siap digunakan!');
|
||||
state.ready = true;
|
||||
state.isReconnecting = false;
|
||||
state.isStarting = false;
|
||||
state.qr = null;
|
||||
handleHook({ eventType: "ready" });
|
||||
if (state.reconnectTimeout) {
|
||||
clearTimeout(state.reconnectTimeout);
|
||||
state.reconnectTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
client.on('auth_failure', (msg) => {
|
||||
log('❌ Autentikasi gagal:', msg);
|
||||
state.ready = false;
|
||||
handleHook({ eventType: "auth_failure", msg });
|
||||
});
|
||||
|
||||
client.on('disconnected', async (reason) => {
|
||||
log('⚠️ Client terputus:', reason);
|
||||
state.ready = false;
|
||||
handleHook({ eventType: "disconnected", reason });
|
||||
|
||||
if (state.reconnectTimeout) clearTimeout(state.reconnectTimeout);
|
||||
|
||||
state.isReconnecting = true;
|
||||
log('⏳ Mencoba reconnect dalam 5 detik...');
|
||||
|
||||
state.reconnectTimeout = setTimeout(async () => {
|
||||
handleHook({ eventType: "reconnect" });
|
||||
await startClient();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
|
||||
client.on('message', handleIncomingMessage);
|
||||
|
||||
// === INISIALISASI ===
|
||||
try {
|
||||
await client.initialize();
|
||||
} catch (err) {
|
||||
log('❌ Gagal inisialisasi client:', err);
|
||||
log('⏳ Mencoba reconnect dalam 10 detik...');
|
||||
state.reconnectTimeout = setTimeout(async () => {
|
||||
state.isReconnecting = false;
|
||||
await startClient();
|
||||
}, 10000);
|
||||
handleHook({ eventType: "reconnect" });
|
||||
} finally {
|
||||
state.isStarting = false;
|
||||
client.on('ready', () => {
|
||||
connectedAt = Date.now();
|
||||
log('✅ WhatsApp client siap digunakan!');
|
||||
state.ready = true;
|
||||
state.isReconnecting = false;
|
||||
state.isStarting = false;
|
||||
state.qr = null;
|
||||
handleHook({ eventType: "ready" });
|
||||
if (state.reconnectTimeout) {
|
||||
clearTimeout(state.reconnectTimeout);
|
||||
state.reconnectTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
client.on('auth_failure', (msg) => {
|
||||
log('❌ Autentikasi gagal:', msg);
|
||||
state.ready = false;
|
||||
handleHook({ eventType: "auth_failure", msg });
|
||||
});
|
||||
|
||||
client.on('disconnected', async (reason) => {
|
||||
log('⚠️ Client terputus:', reason);
|
||||
state.ready = false;
|
||||
handleHook({ eventType: "disconnected", reason });
|
||||
|
||||
if (state.reconnectTimeout) clearTimeout(state.reconnectTimeout);
|
||||
|
||||
state.isReconnecting = true;
|
||||
log('⏳ Mencoba reconnect dalam 5 detik...');
|
||||
|
||||
state.reconnectTimeout = setTimeout(async () => {
|
||||
handleHook({ eventType: "reconnect" });
|
||||
await startClient();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
|
||||
client.on('message', handleIncomingMessage);
|
||||
|
||||
// === INISIALISASI ===
|
||||
try {
|
||||
await client.initialize();
|
||||
} catch (err) {
|
||||
log('❌ Gagal inisialisasi client:', err);
|
||||
log('⏳ Mencoba reconnect dalam 10 detik...');
|
||||
state.reconnectTimeout = setTimeout(async () => {
|
||||
state.isReconnecting = false;
|
||||
await startClient();
|
||||
}, 10000);
|
||||
handleHook({ eventType: "reconnect" });
|
||||
} finally {
|
||||
state.isStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// === HANDLER PESAN MASUK ===
|
||||
async function handleIncomingMessage(msg: WAWebJS.Message) {
|
||||
|
||||
const chat = await msg.getChat();
|
||||
const chat = await msg.getChat();
|
||||
|
||||
// await chat.sendStateTyping();
|
||||
log(`💬 Pesan dari ${msg.from}: ${msg.body || '[MEDIA]'}`);
|
||||
// await chat.sendStateTyping();
|
||||
log(`💬 Pesan dari ${msg.from}: ${msg.body || '[MEDIA]'}`);
|
||||
|
||||
if (!connectedAt) return;
|
||||
if (msg.timestamp * 1000 < connectedAt) return;
|
||||
if (!connectedAt) return;
|
||||
if (msg.timestamp * 1000 < connectedAt) return;
|
||||
|
||||
if (msg.from.endsWith('@g.us') || msg.isStatus || msg.from === 'status@broadcast') {
|
||||
log(`🚫 Pesan dari grup/status diabaikan (${msg.from})`);
|
||||
return;
|
||||
}
|
||||
if (msg.from.endsWith('@g.us') || msg.isStatus || msg.from === 'status@broadcast') {
|
||||
log(`🚫 Pesan dari grup/status diabaikan (${msg.from})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.hasMedia) {
|
||||
const media = await msg.downloadMedia();
|
||||
(msg as any).media = media;
|
||||
}
|
||||
if (msg.hasMedia) {
|
||||
const media = await msg.downloadMedia();
|
||||
(msg as any).media = media;
|
||||
}
|
||||
|
||||
handleHook({ eventType: "message", ...msg })
|
||||
handleHook({ eventType: "message", ...msg })
|
||||
|
||||
|
||||
}
|
||||
|
||||
// === CLEANUP SAAT EXIT ===
|
||||
process.on('SIGINT', () => {
|
||||
log('🛑 SIGINT diterima, menutup client...');
|
||||
destroyClient().then(() => {
|
||||
process.exit(0);
|
||||
}).catch((err) => {
|
||||
log('⚠️ Error saat destroyClient:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
log('🛑 SIGINT diterima, menutup client...');
|
||||
destroyClient().then(() => {
|
||||
process.exit(0);
|
||||
}).catch((err) => {
|
||||
log('⚠️ Error saat destroyClient:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -249,5 +249,5 @@ const getState = () => state;
|
||||
export { destroyClient, getState, startClient };
|
||||
|
||||
if (import.meta.main) {
|
||||
await startClient();
|
||||
await startClient();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import _ from "lodash";
|
||||
import mime from "mime-types";
|
||||
import { MessageMedia } from "whatsapp-web.js";
|
||||
|
||||
const formatJid = (num: string) => {
|
||||
if (num.includes("@")) return num;
|
||||
return `${num}@c.us`;
|
||||
};
|
||||
|
||||
const WaRoute = new Elysia({
|
||||
prefix: "/wa",
|
||||
tags: ["WhatsApp"]
|
||||
@@ -66,7 +71,7 @@ const WaRoute = new Elysia({
|
||||
}
|
||||
|
||||
|
||||
const chat = await client.getChatById(`${body.number}@c.us`);
|
||||
const chat = await client.getChatById(formatJid(body.number));
|
||||
await chat.sendMessage(body.text);
|
||||
|
||||
return {
|
||||
@@ -96,7 +101,7 @@ const WaRoute = new Elysia({
|
||||
|
||||
try {
|
||||
const { number, caption, media } = body;
|
||||
const jid = `${number}@c.us`;
|
||||
const jid = formatJid(number);
|
||||
|
||||
// Siapkan data media
|
||||
const { data, filename, mimetype } = media;
|
||||
@@ -138,7 +143,7 @@ const WaRoute = new Elysia({
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
number: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"] }),
|
||||
number: t.String({ minLength: 5, maxLength: 50, examples: ["6281234567890", "1234567890@lid"] }),
|
||||
caption: t.Optional(t.String({ maxLength: 255, examples: ["Hello World"] })),
|
||||
media: t.Object({
|
||||
data: t.String({ examples: ["iVBORw0KGgoAAAANSUhEUgAAAAEAAAABC..."], description: "Base64 encoded media data" }),
|
||||
@@ -179,14 +184,14 @@ const WaRoute = new Elysia({
|
||||
};
|
||||
}
|
||||
|
||||
const chat = await state.client.sendMessage(`${nom}@c.us`, text);
|
||||
const chat = await state.client.sendMessage(formatJid(nom as string), text as string);
|
||||
return {
|
||||
message: "✅ Message sent",
|
||||
info: chat.id,
|
||||
};
|
||||
}, {
|
||||
query: t.Object({
|
||||
nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"] }),
|
||||
nom: t.String({ minLength: 5, maxLength: 50, examples: ["6281234567890", "1234567890@lid"] }),
|
||||
text: t.String({ examples: ["Hello World"] }),
|
||||
}),
|
||||
detail: {
|
||||
@@ -221,7 +226,7 @@ const WaRoute = new Elysia({
|
||||
};
|
||||
}
|
||||
|
||||
const chat = await state.client.getChatById(`${nom}@c.us`);
|
||||
const chat = await state.client.getChatById(formatJid(nom as string));
|
||||
// await chat.sendSeen();
|
||||
return {
|
||||
message: "✅ Seen sent",
|
||||
@@ -229,7 +234,7 @@ const WaRoute = new Elysia({
|
||||
};
|
||||
}, {
|
||||
query: t.Object({
|
||||
nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"] }),
|
||||
nom: t.String({ minLength: 5, maxLength: 50, examples: ["6281234567890", "1234567890@lid"] }),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Send seen to WhatsApp",
|
||||
@@ -263,7 +268,7 @@ const WaRoute = new Elysia({
|
||||
};
|
||||
}
|
||||
|
||||
const chat = await state.client.getChatById(`${nom}@c.us`);
|
||||
const chat = await state.client.getChatById(formatJid(nom as string));
|
||||
await chat.sendStateTyping();
|
||||
return {
|
||||
message: "✅ Typing sent",
|
||||
@@ -271,7 +276,7 @@ const WaRoute = new Elysia({
|
||||
};
|
||||
}, {
|
||||
query: t.Object({
|
||||
nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"] }),
|
||||
nom: t.String({ minLength: 5, maxLength: 50, examples: ["6281234567890", "1234567890@lid"] }),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Send typing to WhatsApp",
|
||||
|
||||
39
x.ts
Normal file
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