Deploy: 2026-05-22 03:37:23

This commit is contained in:
Hamza-Ayed
2026-05-22 03:37:23 +03:00
parent 479aedcbcf
commit 8448f13dfc
5 changed files with 761 additions and 2 deletions

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Security;
use App\Models\SallaMerchant;
use App\Models\WhatsAppSession;
class SallaController extends BaseController
{
/**
* Redirect user to Salla OAuth login page.
* Accessible via GET /api/integrations/salla/auth?token=JWT_TOKEN
*/
public function auth(Request $request, Response $response)
{
$token = $_GET['token'] ?? '';
if (empty($token)) {
$response->status(400)->html("<h3>Error: Token is required</h3>");
return;
}
$payload = Security::verifyJWT($token);
if (!$payload) {
$response->status(401)->html("<h3>Error: Invalid token</h3>");
return;
}
$companyId = $payload['company_id'];
$clientId = getenv('SALLA_CLIENT_ID') ?: '69ea789c-f611-4ea7-a3ee-7ead41420225';
$redirectUri = getenv('APP_URL') . '/api/integrations/salla/callback';
$authUrl = "https://accounts.salla.sa/oauth2/auth?" . http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'scope' => 'read_orders read_customers',
'state' => $companyId
]);
header("Location: " . $authUrl);
exit;
}
/**
* Handle Salla OAuth callback.
* Accessible via GET /api/integrations/salla/callback?code=CODE&state=COMPANY_ID
*/
public function callback(Request $request, Response $response)
{
$code = $_GET['code'] ?? '';
$companyId = $_GET['state'] ?? '';
if (empty($code) || empty($companyId)) {
$response->status(400)->html("<h3>Error: Missing authorization code or state (company_id).</h3>");
return;
}
$clientId = getenv('SALLA_CLIENT_ID') ?: '69ea789c-f611-4ea7-a3ee-7ead41420225';
$clientSecret = getenv('SALLA_CLIENT_SECRET') ?: '';
$redirectUri = getenv('APP_URL') . '/api/integrations/salla/callback';
// 1. Swap authorization code for token
$ch = curl_init('https://accounts.salla.sa/oauth2/token');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'client_id' => $clientId,
'client_secret' => $clientSecret,
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirectUri
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/x-www-form-urlencoded'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("Salla Token Error: HTTP $httpCode - Response: $res");
$response->status(500)->html("<h3>Failed to exchange code for Salla token. Status code: $httpCode</h3>");
return;
}
$tokenData = json_decode($res, true);
if (empty($tokenData['access_token'])) {
$response->status(500)->html("<h3>Failed to parse Salla token response.</h3>");
return;
}
$accessToken = $tokenData['access_token'];
$refreshToken = $tokenData['refresh_token'];
$expiresIn = $tokenData['expires_in'] ?? 2592000;
$expiresAt = date('Y-m-d H:i:s', time() + $expiresIn);
// 2. Fetch merchant/user info
$chInfo = curl_init('https://accounts.salla.sa/oauth2/user/info');
curl_setopt($chInfo, CURLOPT_RETURNTRANSFER, true);
curl_setopt($chInfo, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken
]);
curl_setopt($chInfo, CURLOPT_TIMEOUT, 10);
$resInfo = curl_exec($chInfo);
$infoHttpCode = curl_getinfo($chInfo, CURLINFO_HTTP_CODE);
curl_close($chInfo);
$storeName = 'Salla Store';
$merchantId = '';
if ($infoHttpCode === 200) {
$infoData = json_decode($resInfo, true);
$merchantId = $infoData['merchant']['id'] ?? $infoData['id'] ?? '';
$storeName = $infoData['merchant']['name'] ?? 'Salla Store';
}
if (empty($merchantId)) {
error_log("Salla Info Fetch Error: HTTP $infoHttpCode - Response: $resInfo");
$response->status(500)->html("<h3>Failed to fetch merchant profile from Salla.</h3>");
return;
}
// 3. Save or update Salla merchant settings in DB
SallaMerchant::saveSecure([
'company_id' => (int)$companyId,
'merchant_id' => (string)$merchantId,
'store_name' => $storeName,
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_at' => $expiresAt
]);
// Redirect back to frontend
header("Location: " . getenv('APP_URL') . "/?salla_connect=success");
exit;
}
/**
* Get Salla connection status for current company.
* Protected by AuthMiddleware.
* Accessible via GET /api/integrations/salla/status
*/
public function status(Request $request, Response $response)
{
$merchant = SallaMerchant::findByCompany($request->company_id);
if ($merchant) {
$response->json([
'status' => 'success',
'connected' => true,
'store_name' => $merchant['store_name'],
'merchant_id' => $merchant['merchant_id'],
'expires_at' => $merchant['expires_at']
]);
} else {
$response->json([
'status' => 'success',
'connected' => false
]);
}
}
/**
* Disconnect Salla integration.
* Protected by AuthMiddleware.
* Accessible via POST /api/integrations/salla/disconnect
*/
public function disconnect(Request $request, Response $response)
{
SallaMerchant::deleteByCompany($request->company_id);
$response->json([
'status' => 'success',
'message' => 'Salla integration disconnected successfully'
]);
}
/**
* Handle incoming Webhook from Salla.
* Accessible via POST /api/webhooks/salla
*/
public function webhook(Request $request, Response $response)
{
// 1. Get raw input and signature
$rawPayload = file_get_contents('php://input');
$signature = $request->getHeader('x-salla-signature') ?: '';
// 2. Verify signature
$secret = getenv('SALLA_WEBHOOK_SECRET') ?: '406550c38b782a50e9a3e0687f564107f8a60135b072b1f819470dda5333a65d';
$calculated = hash_hmac('sha256', $rawPayload, $secret);
if (!hash_equals($calculated, $signature)) {
error_log("[Salla Webhook Error] Invalid signature. Received: $signature, Calculated: $calculated");
$response->status(401)->json(['error' => 'Invalid signature']);
return;
}
// 3. Parse JSON payload
$payload = json_decode($rawPayload, true);
if (!$payload) {
$response->status(400)->json(['error' => 'Invalid JSON']);
return;
}
$event = $payload['event'] ?? '';
$merchantId = $payload['merchant'] ?? '';
if (empty($event) || empty($merchantId)) {
$response->status(400)->json(['error' => 'Missing event or merchant information']);
return;
}
// 4. Find the merchant config to get company_id
$merchant = SallaMerchant::findByMerchantId((string)$merchantId);
if (!$merchant) {
error_log("[Salla Webhook Warning] Webhook received for unlinked merchant ID: " . $merchantId);
$response->status(404)->json(['error' => 'Store is not linked to any company in Nabeh']);
return;
}
$companyId = (int)$merchant['company_id'];
$orderData = $payload['data'] ?? [];
// 5. Process events
if ($event === 'order.created' || $event === 'order.status.updated') {
$this->handleOrderWebhook($companyId, $event, $orderData);
}
$response->json(['status' => 'success']);
}
/**
* Process order webhook events and send automated WhatsApp message
*/
private function handleOrderWebhook(int $companyId, string $event, array $orderData)
{
$orderId = $orderData['id'] ?? $orderData['reference_id'] ?? '';
$customerName = $orderData['customer']['name'] ?? 'عميلنا العزيز';
$customerPhone = $orderData['customer']['mobile'] ?? $orderData['customer']['phone'] ?? '';
$statusName = $orderData['status']['name'] ?? '';
if (empty($customerPhone) || empty($orderId)) {
return;
}
// Format phone: remove leading zero or +, ensure only digits
$customerPhone = preg_replace('/\D/', '', $customerPhone);
if (substr($customerPhone, 0, 2) === '00') {
$customerPhone = substr($customerPhone, 2);
}
// Normalize Saudi numbers starting with 05
if (strlen($customerPhone) === 9 && $customerPhone[0] === '5') {
$customerPhone = '966' . $customerPhone;
} elseif (strlen($customerPhone) === 10 && substr($customerPhone, 0, 2) === '05') {
$customerPhone = '966' . substr($customerPhone, 1);
}
// Construct message based on event
$message = '';
if ($event === 'order.created') {
$message = "مرحباً {$customerName}،\nتم استلام طلبك رقم ({$orderId}) بنجاح! شكرًا لتسوقك معنا. سنقوم بتحديثك فور تغيير حالة الطلب.";
} elseif ($event === 'order.status.updated') {
$message = "مرحباً {$customerName}،\nتم تحديث حالة طلبك رقم ({$orderId}) إلى: *{$statusName}*.";
// Check for shipping details
$courier = $orderData['shipment']['courier_name'] ?? '';
$tracking = $orderData['shipment']['tracking_link'] ?? '';
if (!empty($courier) && !empty($tracking)) {
$message .= "\n🚚 الشحن عبر: {$courier}\nرابط التتبع: {$tracking}";
}
}
if (empty($message)) {
return;
}
// Send WhatsApp Message via active session
$session = WhatsAppSession::findByCompany($companyId);
if (!$session || $session['status'] !== 'connected') {
error_log("[Salla Webhook Warning] Cannot send auto-notification: No active connected WhatsApp session for company: " . $companyId);
return;
}
$this->sendWhatsAppNotification($session['session_key'], $customerPhone, $message);
}
/**
* Send a WhatsApp message through the gateway
*/
private function sendWhatsAppNotification(string $sessionKey, string $phone, string $message)
{
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
if (substr($gatewayUrl, -4) === '/api') {
$sendUrl = $gatewayUrl . '/messages/send';
} else {
$sendUrl = $gatewayUrl . '/api/messages/send';
}
$payload = json_encode([
'session_key' => $sessionKey,
'phone' => $phone,
'message' => $message
]);
$ch = curl_init($sendUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Webhook-Secret: ' . getenv('WEBHOOK_SECRET')
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[Salla Webhook Gateway Error] Failed to send WhatsApp notification. Gateway response: " . $response);
}
}
}

View File

@@ -343,12 +343,21 @@ class WhatsAppController extends BaseController
$infoContext = $this->fetchUserInfoFromEndpoint($infoEndpoint, $msgData['phone']);
}
// Dynamically fetch Salla order context if connected
$sallaContext = "";
if (!empty($msgData['phone'])) {
$sallaContext = $this->fetchSallaOrderContext($session['company_id'], $msgData['phone'], $incomingText);
}
$systemPrompt = $rule['ai_prompt'] ?: 'You are a helpful customer support assistant.';
// Append real-time info context to Gemini system prompt
if (!empty($infoContext)) {
$systemPrompt .= "\n\n" . $infoContext;
}
if (!empty($sallaContext)) {
$systemPrompt .= "\n\n" . $sallaContext;
}
// Enforce language matching rule dynamically
$systemPrompt .= "\n\nIMPORTANT LANGUAGE RULE: Detect the language of the incoming message. If the incoming message is in English, you MUST reply in English. If the incoming message is in Arabic, you MUST reply in Arabic. Override any default language instruction to match the user's language.";
@@ -578,4 +587,124 @@ class WhatsAppController extends BaseController
}
return "";
}
/**
* Fetch order info context from Salla e-commerce platform for the company
*/
private function fetchSallaOrderContext(int $companyId, string $phone, string $incomingText): string
{
try {
$accessToken = \App\Models\SallaMerchant::getOrRefreshAccessToken($companyId);
if (!$accessToken) {
return ""; // Salla is not integrated
}
// Standardize customer phone to compare last 9 digits
$cleanPhone = preg_replace('/\D/', '', $phone);
if (empty($cleanPhone)) {
return "";
}
// 1. Check if user is asking about a specific order ID (e.g. sequence of 5-12 digits)
if (preg_match('/\b(\d{5,12})\b/', $incomingText, $matches)) {
$orderId = $matches[1];
$ch = curl_init("https://api.salla.dev/admin/v2/orders/{$orderId}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$orderRes = json_decode($response, true);
$order = $orderRes['data'] ?? null;
if ($order) {
$orderCustPhone = preg_replace('/\D/', '', $order['customer']['mobile'] ?? $order['customer']['phone'] ?? '');
// Security check: match last 9 digits of WhatsApp phone to order customer phone
if (substr($orderCustPhone, -9) === substr($cleanPhone, -9)) {
$status = $order['status']['name'] ?? 'غير معروف';
$total = $order['amounts']['total']['amount'] ?? $order['total'] ?? '';
$currency = $order['amounts']['total']['currency'] ?? 'SAR';
$courier = $order['shipment']['courier_name'] ?? '';
$tracking = $order['shipment']['tracking_link'] ?? '';
$itemsCount = count($order['items'] ?? []);
$context = "\n\n[تفاصيل طلب سلة المستعلم عنه للعميل:\n";
$context .= "- رقم الطلب: {$orderId}\n";
$context .= "- حالة الطلب الحالية: {$status}\n";
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
$context .= "- عدد المنتجات: {$itemsCount}\n";
if (!empty($courier)) {
$context .= "- شركة الشحن: {$courier}\n";
}
if (!empty($tracking)) {
$context .= "- رابط تتبع الشحنة: {$tracking}\n";
}
$context .= "الرجاء صياغة رد ودود ومختصر باللغة العربية لإخبار العميل بحالة هذا الطلب بالتحديد]";
return $context;
} else {
// Order ID exists but phone mismatch
return "\n\n[تنبيه أمني للذكاء الاصطناعي: العميل سأل عن الطلب رقم {$orderId} ولكن هذا الطلب مسجل برقم هاتف مختلف في سلة. لحماية الخصوصية والأمان، يمنع منعاً باتاً عرض تفاصيل هذا الطلب له. أخبر العميل بلطف أن رقم الهاتف الحالي لا يتطابق مع رقم الهاتف المسجل في تفاصيل هذا الطلب ولا يمكنك كشف تفاصيله]";
}
}
}
}
// 2. Fetch list of recent orders for the merchant and search by customer phone number
$ch = curl_init("https://api.salla.dev/admin/v2/orders?page=1");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$ordersRes = json_decode($response, true);
$orders = $ordersRes['data'] ?? [];
if (is_array($orders)) {
foreach ($orders as $order) {
$orderCustPhone = preg_replace('/\D/', '', $order['customer']['mobile'] ?? $order['customer']['phone'] ?? '');
if (substr($orderCustPhone, -9) === substr($cleanPhone, -9)) {
// Found the most recent order for this customer
$orderId = $order['id'] ?? $order['reference_id'] ?? '';
$status = $order['status']['name'] ?? 'غير معروف';
$total = $order['amounts']['total']['amount'] ?? $order['total'] ?? '';
$currency = $order['amounts']['total']['currency'] ?? 'SAR';
$courier = $order['shipment']['courier_name'] ?? '';
$tracking = $order['shipment']['tracking_link'] ?? '';
$itemsCount = count($order['items'] ?? []);
$context = "\n\n[آخر طلب للعميل في متجر سلة:\n";
$context .= "- رقم الطلب: {$orderId}\n";
$context .= "- حالة الطلب الحالية: {$status}\n";
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
$context .= "- عدد المنتجات: {$itemsCount}\n";
if (!empty($courier)) {
$context .= "- شركة الشحن: {$courier}\n";
}
if (!empty($tracking)) {
$context .= "- رابط تتبع الشحنة: {$tracking}\n";
}
$context .= "الرجاء استخدام هذه التفاصيل للإجابة على استفساره حول حالة طلبه الأخير بدقة وود باللغة العربية]";
return $context;
}
}
}
}
return "\n\n[سياق المتجر: العميل متصل بمتجر سلة ولكن لم يتم العثور على أي طلبات سابقة له برقم الهاتف هذا. أخبره بلطف أنه لا توجد طلبات سابقة مسجلة برقم هاتفه الحالي في المتجر، واطلب منه تزويدك برقم الطلب أو البريد الإلكتروني للبحث]";
} catch (\Exception $e) {
error_log("[Fetch Salla Order Exception] " . $e->getMessage());
}
return "";
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Models;
use App\Core\Database;
/**
* SallaMerchant Model
* Handles database representation of Salla e-commerce store links per company.
*/
class SallaMerchant extends BaseModel
{
protected static string $table = 'salla_merchants';
/**
* Find merchant info by company ID
*/
public static function findByCompany(int $companyId): ?array
{
self::ensureTableExists();
return Database::selectOne(
"SELECT * FROM " . static::$table . " WHERE company_id = ? LIMIT 1",
[$companyId]
);
}
/**
* Find merchant info by Salla merchant ID
*/
public static function findByMerchantId(string $merchantId): ?array
{
self::ensureTableExists();
return Database::selectOne(
"SELECT * FROM " . static::$table . " WHERE merchant_id = ? LIMIT 1",
[$merchantId]
);
}
/**
* Save or update Salla merchant credentials
*/
public static function saveSecure(array $data): string
{
self::ensureTableExists();
$existing = self::findByCompany($data['company_id']);
if ($existing) {
self::update($existing['id'], $data);
return $existing['id'];
} else {
return self::create($data);
}
}
/**
* Delete credentials for a company
*/
public static function deleteByCompany(int $companyId): int
{
self::ensureTableExists();
return Database::execute(
"DELETE FROM " . static::$table . " WHERE company_id = ?",
[$companyId]
);
}
/**
* Get or dynamically refresh the access token for a company
*/
public static function getOrRefreshAccessToken(int $companyId): ?string
{
$merchant = self::findByCompany($companyId);
if (!$merchant) {
return null;
}
// Check if token expires_at is in the future (> 5 mins remaining)
$expiresAt = strtotime($merchant['expires_at']);
if ($expiresAt && ($expiresAt - time()) > 300) {
return $merchant['access_token'];
}
// Token expired or close to it, refresh
$clientId = getenv('SALLA_CLIENT_ID') ?: '69ea789c-f611-4ea7-a3ee-7ead41420225';
$clientSecret = getenv('SALLA_CLIENT_SECRET') ?: '';
$ch = curl_init('https://accounts.salla.sa/oauth2/token');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'client_id' => $clientId,
'client_secret' => $clientSecret,
'grant_type' => 'refresh_token',
'refresh_token' => $merchant['refresh_token']
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/x-www-form-urlencoded'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("Failed to refresh Salla token for company $companyId. Response: $res");
return null;
}
$tokenData = json_decode($res, true);
if (empty($tokenData['access_token'])) {
return null;
}
$accessToken = $tokenData['access_token'];
$refreshToken = $tokenData['refresh_token'] ?? $merchant['refresh_token'];
$expiresIn = $tokenData['expires_in'] ?? 2592000;
$newExpiresAt = date('Y-m-d H:i:s', time() + $expiresIn);
self::saveSecure([
'company_id' => $companyId,
'merchant_id' => $merchant['merchant_id'],
'store_name' => $merchant['store_name'],
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_at' => $newExpiresAt
]);
return $accessToken;
}
/**
* Ensure the salla_merchants table exists
*/
public static function ensureTableExists(): void
{
static $checked = false;
if ($checked) return;
try {
Database::execute("
CREATE TABLE IF NOT EXISTS `salla_merchants` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`merchant_id` VARCHAR(100) NOT NULL UNIQUE,
`store_name` VARCHAR(255) NULL,
`access_token` TEXT NOT NULL,
`refresh_token` TEXT NOT NULL,
`expires_at` TIMESTAMP NULL DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
$checked = true;
} catch (\Exception $e) {
error_log("Failed to ensure salla_merchants table: " . $e->getMessage());
}
}
}

View File

@@ -774,13 +774,26 @@
<button class="nav-item" :class="{ 'active': activeDashboardTab === 'chatbot' }" @click="activeDashboardTab = 'chatbot'; fetchChatbotSettings()" id="nav-chatbot-btn">
<span>🤖</span> <span x-text="lang === 'ar' ? 'روبوت الذكاء الاصطناعي' : 'AI Chatbot Settings'"></span>
</button>
<button class="nav-item" :class="{ 'active': activeDashboardTab === 'integrations' }" @click="activeDashboardTab = 'integrations'; fetchEndpoints()" id="nav-integrations-btn">
<span>🔌</span> <span>ربط تطبيق نبيه بمشروعك (API Integrations)</span>
<button class="nav-item" :class="{ 'active': activeDashboardTab === 'integrations' }" @click="activeDashboardTab = 'integrations'; fetchEndpoints(); fetchSallaStatus()" id="nav-integrations-btn">
<span>🔌</span> <span x-text="lang === 'ar' ? 'الربط البرمجي والمنصات (Integrations)' : 'API & Platform Integrations'"></span>
</button>
</div>
<!-- Right Dashboard Panels -->
<div style="flex: 1;">
<!-- Global Dashboard Banner -->
<template x-if="dashboardSuccess">
<div class="banner banner-success" style="margin-bottom: 1.5rem;" id="dashboard-success-banner">
<span x-text="dashboardSuccess"></span>
<button @click="dashboardSuccess = ''" style="background: none; border: none; color: inherit; font-size: 1.2rem; cursor: pointer; margin-left: auto; margin-right: 0;" id="dashboard-success-close-btn">&times;</button>
</div>
</template>
<template x-if="dashboardError">
<div class="banner banner-danger" style="margin-bottom: 1.5rem;" id="dashboard-error-banner">
<span x-text="dashboardError"></span>
<button @click="dashboardError = ''" style="background: none; border: none; color: inherit; font-size: 1.2rem; cursor: pointer; margin-left: auto; margin-right: 0;" id="dashboard-error-close-btn">&times;</button>
</div>
</template>
<!-- Panel: WhatsApp Connection -->
<div class="panel" x-show="activeDashboardTab === 'whatsapp'" id="panel-whatsapp">
@@ -1113,6 +1126,50 @@
<p class="text-muted" style="margin-bottom: 1.5rem; font-size: 0.9rem;" x-text="lang === 'ar' ? 'قم بتهيئة واجهات برمجة التطبيقات الخارجية (Web APIs) للربط البرمجي بمشروعك. يمكن للروبوت جلب الملفات التعريفية للمستخدمين أو التحقق من تفاصيل الدفع ديناميكيًا.' : 'Configure external web APIs for multi-tenant integrations. The chatbot can fetch user profiles or verify payment details dynamically.'">
</p>
<!-- Salla Integration Section -->
<div style="background: rgba(0, 178, 137, 0.05); border: 1px solid rgba(0, 178, 137, 0.15); border-radius: 12px; padding: 1.5rem; margin-bottom: 2rem; box-shadow: 0 4px 20px rgba(0, 178, 137, 0.05);">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<div style="display: flex; align-items: center; gap: 1rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse; text-align: right;' : ''">
<div style="background: #00b289; width: 48px; height: 48px; border-radius: 10px; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 15px rgba(0, 178, 137, 0.4);">
<span style="font-size: 1.5rem;">🛍️</span>
</div>
<div>
<h3 style="font-size: 1.2rem; margin: 0; display: flex; align-items: center; gap: 0.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<span x-text="lang === 'ar' ? 'ربط متجر سلة (Salla)' : 'Salla Store Integration'"></span>
<template x-if="sallaStatus && sallaStatus.connected">
<span class="status-badge badge-connected" style="margin: 0; padding: 0.15rem 0.5rem; font-size: 0.7rem;" x-text="lang === 'ar' ? 'متصل' : 'Connected'"></span>
</template>
<template x-if="!sallaStatus || !sallaStatus.connected">
<span class="status-badge badge-disconnected" style="margin: 0; padding: 0.15rem 0.5rem; font-size: 0.7rem;" x-text="lang === 'ar' ? 'غير متصل' : 'Disconnected'"></span>
</template>
</h3>
<p class="text-muted" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;" x-text="lang === 'ar' ? 'قم بربط متجر سلة الخاص بك لتفعيل الاستعلام التلقائي عن الطلبات وتتبع الشحنات وإرسال تنبيهات التحديثات للعملاء عبر الواتساب.' : 'Connect your Salla store to enable automatic order tracking and send customer updates via WhatsApp using Gemini AI.'"></p>
</div>
</div>
<div style="display: flex; gap: 0.75rem; align-items: center;">
<template x-if="sallaLoading">
<span class="spinner"></span>
</template>
<template x-if="!sallaLoading && (!sallaStatus || !sallaStatus.connected)">
<button @click="connectSalla()" class="btn" style="background: linear-gradient(135deg, #00b289 0%, #009673 100%); color: #fff; width: auto; font-size: 0.9rem; padding: 0.6rem 1.2rem; box-shadow: 0 4px 12px rgba(0, 178, 137, 0.3);" id="connect-salla-btn">
<span x-text="lang === 'ar' ? 'ربط المتجر الآن' : 'Connect Store'"></span>
</button>
</template>
<template x-if="!sallaLoading && sallaStatus && sallaStatus.connected">
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<div style="text-align: right;" :style="lang === 'ar' ? 'text-align: right;' : 'text-align: left;'">
<span style="font-size: 0.8rem; color: var(--text-secondary);" x-text="lang === 'ar' ? 'المتجر المرتبط:' : 'Connected Store:'"></span>
<strong style="display: block; font-size: 0.95rem; color: #fff;" x-text="sallaStatus.store_name"></strong>
</div>
<button @click="disconnectSalla()" class="btn btn-danger" style="width: auto; font-size: 0.9rem; padding: 0.6rem 1.2rem;" id="disconnect-salla-btn">
<span x-text="lang === 'ar' ? 'إلغاء الربط' : 'Disconnect'"></span>
</button>
</div>
</template>
</div>
</div>
</div>
<div class="data-table-container">
<table class="data-table">
<thead>
@@ -1390,6 +1447,12 @@
},
endpoints: [],
// Salla Integration States
sallaStatus: null,
sallaLoading: false,
dashboardSuccess: '',
dashboardError: '',
// Forms
contactName: '',
contactPhone: '',
@@ -1522,12 +1585,25 @@
this.templates = [];
this.campaigns = [];
this.endpoints = [];
this.sallaStatus = null;
this.dashboardSuccess = '';
this.dashboardError = '';
},
initializeDashboard() {
this.fetchWhatsappStatus();
this.fetchSallaStatus();
// Set up persistent background status check
this.startPolling();
// Detect Salla success connect
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('salla_connect') === 'success') {
this.dashboardSuccess = this.lang === 'ar'
? 'تم ربط متجر سلة بنجاح!'
: 'Salla store connected successfully!';
window.history.replaceState({}, document.title, window.location.pathname);
}
},
async fetchWhatsappStatus() {
@@ -1716,6 +1792,65 @@
}
},
// Salla Integration Methods
async fetchSallaStatus() {
if (!this.token) return;
this.sallaLoading = true;
try {
const response = await fetch('/api/integrations/salla/status', {
headers: { 'Authorization': `Bearer ${this.token}` }
});
const data = await response.json();
if (response.ok && data.status === 'success') {
this.sallaStatus = data;
} else {
this.sallaStatus = { connected: false };
}
} catch (err) {
console.error('Error fetching Salla status:', err);
this.sallaStatus = { connected: false };
} finally {
this.sallaLoading = false;
}
},
connectSalla() {
if (!this.token) return;
window.location.href = `/api/integrations/salla/auth?token=${encodeURIComponent(this.token)}`;
},
async disconnectSalla() {
const confirmMsg = this.lang === 'ar'
? 'هل أنت متأكد من رغبتك في إلغاء ربط متجر سلة؟'
: 'Are you sure you want to disconnect your Salla store?';
if (!confirm(confirmMsg)) return;
this.sallaLoading = true;
try {
const response = await fetch('/api/integrations/salla/disconnect', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (response.ok && data.status === 'success') {
this.dashboardSuccess = this.lang === 'ar'
? 'تم إلغاء ربط متجر سلة بنجاح.'
: 'Salla store disconnected successfully.';
await this.fetchSallaStatus();
} else {
this.dashboardError = data.message || 'Failed to disconnect Salla integration.';
}
} catch (err) {
console.error('Error disconnecting Salla:', err);
this.dashboardError = 'Failed to disconnect Salla integration.';
} finally {
this.sallaLoading = false;
}
},
// CRM List Fetchers
async fetchContacts() {
this.selectedContactIds = [];

View File

@@ -73,6 +73,14 @@ $router->get('/api/endpoints', [\App\Controllers\EndpointController::class,
$router->post('/api/endpoints', [\App\Controllers\EndpointController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]);
$router->delete('/api/endpoints', [\App\Controllers\EndpointController::class, 'delete'], [\App\Middlewares\AuthMiddleware::class]);
// Salla Platform Integration Routes (Phase 6+)
$router->get('/api/integrations/salla/auth', [\App\Controllers\SallaController::class, 'auth']);
$router->get('/api/integrations/salla/callback', [\App\Controllers\SallaController::class, 'callback']);
$router->get('/api/integrations/salla/status', [\App\Controllers\SallaController::class, 'status'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/integrations/salla/disconnect',[\App\Controllers\SallaController::class, 'disconnect'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/webhooks/salla', [\App\Controllers\SallaController::class, 'webhook']);
// Mock External API for Entaleq Driver Info (Used to fetch real-time driver data)
$router->post('/api/external/driver-info', function ($request, $response) {
$body = $request->getBody();