Forráskód Böngészése

feat(integrations): add phone number selection to auto-registration flow

- Add card-based phone number selector UI with radio buttons
- Show phone numbers grouped by location with Local/Toll-free badges
- Use Supabase client with anon key for phone number fetching
- Add RLS policy allowing anonymous read of available phone numbers
- Remove city selector step for simplified UX
- Add i18n translations for en, hu, de locales

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh 4 hónapja
szülő
commit
37b292ac73

+ 304 - 0
docs/order_example.json

@@ -0,0 +1,304 @@
+{
+  "href": "http://smartboticsapptest.api.myshoprenter.hu/orderExtend?full=1&innerId=1&page=0&limit=1",
+  "page": 0,
+  "pageCount": 1,
+  "limit": 1,
+  "first": {
+    "href": "http://smartboticsapptest.api.myshoprenter.hu/orderExtend?full=1&innerId=1&page=0&limit=1"
+  },
+  "previous": null,
+  "next": null,
+  "last": {
+    "href": "http://smartboticsapptest.api.myshoprenter.hu/orderExtend?full=1&innerId=1&page=0&limit=1"
+  },
+  "items": [
+    {
+      "href": "http://smartboticsapptest.api.myshoprenter.hu/orderExtend/b3JkZXItb3JkZXJfaWQ9MQ==",
+      "id": "b3JkZXItb3JkZXJfaWQ9MQ==",
+      "innerId": "1",
+      "invoiceId": "1",
+      "invoicePrefix": "001",
+      "firstname": "Ferenc",
+      "lastname": "Szontágh",
+      "phone": "+36309284614",
+      "fax": "",
+      "email": "ferenc.szontagh@smartbotics.ai",
+      "shippingFirstname": "Ferenc",
+      "shippingLastname": "Szontágh",
+      "shippingCompany": "",
+      "shippingAddress1": "Ondi út 40",
+      "shippingAddress2": "",
+      "shippingCity": "Szerencs",
+      "shippingPostcode": "3900",
+      "shippingZoneName": "",
+      "shippingCountryName": "Magyarország",
+      "shippingAddressFormat": "",
+      "shippingMethodName": "Házhozszállítás MPL  futárszolgálattal",
+      "shippingMethodLocalizedName": "Házhozszállítás MPL  futárszolgálattal",
+      "shippingMethodTaxRate": "27.0000",
+      "shippingMethodTaxName": "27 %",
+      "shippingMethodExtension": "wseship",
+      "shippingReceivingPointId": "0",
+      "paymentFirstname": "Ferenc",
+      "paymentLastname": "Szontágh",
+      "paymentCompany": "",
+      "paymentAddress1": "Ondi út 40",
+      "paymentAddress2": "",
+      "paymentCity": "Szerencs",
+      "paymentPostcode": "3900",
+      "paymentZoneName": "",
+      "paymentCountryName": "Magyarország",
+      "paymentAddressFormat": "",
+      "paymentMethodName": "Banki átutalás (előre utalás)",
+      "paymentMethodCode": "bank_transfer",
+      "paymentMethodTaxRate": "27.0000",
+      "paymentMethodTaxName": "27 %",
+      "paymentMethodAfter": "0",
+      "taxNumber": "",
+      "comment": "",
+      "total": "6108.8200",
+      "value": "1.00000000",
+      "couponTaxRate": "-1.0000",
+      "dateCreated": "2025-11-13T17:44:20",
+      "dateUpdated": "2025-11-13T21:36:17",
+      "ip": "88.132.3.140",
+      "pickPackPontShopCode": "",
+      "loyaltyPointsTaxRate": "-1.0000",
+      "customer": {
+        "href": "http://smartboticsapptest.api.myshoprenter.hu/customers/Y3VzdG9tZXItY3VzdG9tZXJfaWQ9Mg=="
+      },
+      "customerGroup": {
+        "href": "http://smartboticsapptest.api.myshoprenter.hu/customerGroups/Y3VzdG9tZXJHcm91cC1jdXN0b21lcl9ncm91cF9pZD04"
+      },
+      "shippingZone": null,
+      "shippingCountry": {
+        "href": "http://smartboticsapptest.api.myshoprenter.hu/countries/Y291bnRyeS1jb3VudHJ5X2lkPTk3",
+        "id": "Y291bnRyeS1jb3VudHJ5X2lkPTk3",
+        "name": "Magyarország",
+        "isoCode2": "HU",
+        "isoCode3": "HUN",
+        "status": "1",
+        "zones": {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/zones?countryId=Y291bnRyeS1jb3VudHJ5X2lkPTk3"
+        }
+      },
+      "paymentZone": null,
+      "paymentCountry": {
+        "href": "http://smartboticsapptest.api.myshoprenter.hu/countries/Y291bnRyeS1jb3VudHJ5X2lkPTk3",
+        "id": "Y291bnRyeS1jb3VudHJ5X2lkPTk3",
+        "name": "Magyarország",
+        "isoCode2": "HU",
+        "isoCode3": "HUN",
+        "status": "1",
+        "zones": {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/zones?countryId=Y291bnRyeS1jb3VudHJ5X2lkPTk3"
+        }
+      },
+      "orderStatus": {
+        "href": "http://smartboticsapptest.api.myshoprenter.hu/orderStatuses/b3JkZXJTdGF0dXMtb3JkZXJfc3RhdHVzX2lkPTE="
+      },
+      "language": {
+        "href": "http://smartboticsapptest.api.myshoprenter.hu/languages/bGFuZ3VhZ2UtbGFuZ3VhZ2VfaWQ9MQ==",
+        "id": "bGFuZ3VhZ2UtbGFuZ3VhZ2VfaWQ9MQ==",
+        "innerId": "1",
+        "name": "Hungarian",
+        "code": "hu",
+        "locale": "hu_HUN.UTF-8,hu_HUN,hu-hun,hungarian",
+        "sortOrder": "1",
+        "status": "1"
+      },
+      "currency": {
+        "href": "http://smartboticsapptest.api.myshoprenter.hu/currencies/Y3VycmVuY3ktY3VycmVuY3lfaWQ9NA==",
+        "id": "Y3VycmVuY3ktY3VycmVuY3lfaWQ9NA==",
+        "name": "HUF",
+        "code": "HUF",
+        "symbolLeft": "",
+        "symbolRight": " Ft",
+        "decimalPlace": "0",
+        "value": "1.00000000",
+        "status": "1",
+        "dateUpdated": "2017-11-29 10:44:07"
+      },
+      "shippingMode": {
+        "href": "http://smartboticsapptest.api.myshoprenter.hu/shippingModes/c2hpcHBpbmdNb2RlLWlkPTIy",
+        "id": "c2hpcHBpbmdNb2RlLWlkPTIy",
+        "innerId": "22",
+        "sortOrder": "0",
+        "enabled": "1",
+        "costCalculationBy": "WEIGHT",
+        "freeShippingFrom": "0.0000",
+        "shippingType": "COURIER_SERVICE",
+        "extension": "WSESHIP",
+        "taxClass": {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/taxClasses/dGF4Q2xhc3MtdGF4X2NsYXNzX2lkPTEw"
+        },
+        "geoZone": null,
+        "shippingModeDescriptions": {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/shippingModeDescriptions?shippingModeId=c2hpcHBpbmdNb2RlLWlkPTIy"
+        },
+        "shippingLanes": {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/shippingLanes?shippingModeId=c2hpcHBpbmdNb2RlLWlkPTIy"
+        }
+      },
+      "coupon": [],
+      "externalInfo": {
+        "partner_order_id": "",
+        "partner_prefix": ""
+      },
+      "furgefutarWaybill": 0,
+      "orderTotals": [
+        {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/orderTotals/b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD0yOQ==",
+          "id": "b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD0yOQ==",
+          "name": "Nettó részösszeg:",
+          "valueText": "3.244 Ft",
+          "value": "3244.0945",
+          "sortOrder": "3",
+          "type": "SUB_TOTAL",
+          "key": "",
+          "description": "",
+          "dateCreated": "1970-01-01T00:00:00",
+          "dateUpdated": "1970-01-01T00:00:00",
+          "order": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/orders/b3JkZXItb3JkZXJfaWQ9MQ=="
+          }
+        },
+        {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/orderTotals/b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD0zMg==",
+          "id": "b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD0zMg==",
+          "name": "ÁFA (27%):",
+          "valueText": "876 Ft",
+          "value": "875.9055",
+          "sortOrder": "3",
+          "type": "TAX",
+          "key": "",
+          "description": "",
+          "dateCreated": "1970-01-01T00:00:00",
+          "dateUpdated": "1970-01-01T00:00:00",
+          "order": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/orders/b3JkZXItb3JkZXJfaWQ9MQ=="
+          }
+        },
+        {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/orderTotals/b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD0zNQ==",
+          "id": "b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD0zNQ==",
+          "name": "Bruttó részösszeg:",
+          "valueText": "4.120 Ft",
+          "value": "4120.0000",
+          "sortOrder": "4",
+          "type": "SUB_TOTAL_WITH_TAX",
+          "key": "",
+          "description": "",
+          "dateCreated": "1970-01-01T00:00:00",
+          "dateUpdated": "1970-01-01T00:00:00",
+          "order": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/orders/b3JkZXItb3JkZXJfaWQ9MQ=="
+          }
+        },
+        {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/orderTotals/b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD0zOA==",
+          "id": "b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD0zOA==",
+          "name": "Házhozszállítás MPL  futárszolgálattal:",
+          "valueText": "1.989 Ft",
+          "value": "1988.8200",
+          "sortOrder": "6",
+          "type": "SHIPPING",
+          "key": "",
+          "description": "",
+          "dateCreated": "1970-01-01T00:00:00",
+          "dateUpdated": "1970-01-01T00:00:00",
+          "order": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/orders/b3JkZXItb3JkZXJfaWQ9MQ=="
+          }
+        },
+        {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/orderTotals/b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD00MQ==",
+          "id": "b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD00MQ==",
+          "name": "Összesen bruttó:",
+          "valueText": "6.109 Ft",
+          "value": "6108.8200",
+          "sortOrder": "10",
+          "type": "TOTAL",
+          "key": "",
+          "description": "",
+          "dateCreated": "1970-01-01T00:00:00",
+          "dateUpdated": "1970-01-01T00:00:00",
+          "order": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/orders/b3JkZXItb3JkZXJfaWQ9MQ=="
+          }
+        },
+        {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/orderTotals/b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD00NA==",
+          "id": "b3JkZXJUb3RhbC1vcmRlcl90b3RhbF9pZD00NA==",
+          "name": "Új hűségpontok:",
+          "valueText": "+ 62",
+          "value": "0.0000",
+          "sortOrder": "9999999",
+          "type": "LOYALTYPOINTS_TO_GET",
+          "key": "",
+          "description": "62",
+          "dateCreated": "1970-01-01T00:00:00",
+          "dateUpdated": "1970-01-01T00:00:00",
+          "order": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/orders/b3JkZXItb3JkZXJfaWQ9MQ=="
+          }
+        }
+      ],
+      "orderProducts": [
+        {
+          "href": "http://smartboticsapptest.api.myshoprenter.hu/orderProducts/b3JkZXJQcm9kdWN0LW9yZGVyX3Byb2R1Y3RfaWQ9OA==",
+          "id": "b3JkZXJQcm9kdWN0LW9yZGVyX3Byb2R1Y3RfaWQ9OA==",
+          "innerId": "8",
+          "name": "Harmonie New Baby Plastic free 4x46db popsitörlő",
+          "sku": "cikkszam1",
+          "productInnerId": "569",
+          "modelNumber": "",
+          "originalPrice": "3244.0945",
+          "originalPriceCurrency": "3244.0945",
+          "price": "3244.0945",
+          "priceCurrency": "3244.0945",
+          "grossPrice": "4120.0000",
+          "grossPriceCurrency": "4120.0000",
+          "originalGrossPrice": "4120.0000",
+          "originalGrossPriceCurrency": "4120.0000",
+          "total": "3244.0945",
+          "taxRate": "27.0000",
+          "stock1": "1",
+          "stock2": "0",
+          "stock3": "0",
+          "stock4": "0",
+          "subtractStock": "1",
+          "dateCreated": "2025-11-13T17:44:32",
+          "dateUpdated": "2025-11-13T17:44:32",
+          "width": "0.00",
+          "height": "0.00",
+          "length": "0.00",
+          "weight": "0.00",
+          "parentProduct": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/products/cHJvZHVjdC1wcm9kdWN0X2lkPTU2OQ=="
+          },
+          "gtin": "",
+          "durableMediaDevice": "0",
+          "order": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/orders/b3JkZXItb3JkZXJfaWQ9MQ=="
+          },
+          "product": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/products/cHJvZHVjdC1wcm9kdWN0X2lkPTU2OQ=="
+          },
+          "orderProductOptions": {
+            "href": "http://smartboticsapptest.api.myshoprenter.hu/orderProductOptions?orderProductId=b3JkZXJQcm9kdWN0LW9yZGVyX3Byb2R1Y3RfaWQ9OA=="
+          }
+        }
+      ],
+      "orderGiftWrappings": [],
+      "orderProductAddons": [],
+      "orderAddressIdentifications": [
+        {
+          "shippingAddressIdentification": "0",
+          "paymentAddressIdentification": "0"
+        }
+      ],
+      "orderCreditCards": []
+    }
+  ]
+}

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 35 - 0
docs/vapi-webhook_example_payload.json


+ 5 - 0
shopcall.ai-main/.env.example

@@ -26,3 +26,8 @@ VITE_HIDE_CUSTOMERS_ACCESS_SETTINGS=true
 # For local development: http://127.0.0.1:3000
 # For production: https://your-scraper-api.com
 VITE_DEFAULT_SCRAPER_API_URL=http://127.0.0.1:3000
+
+# Call Log Analytics Feature Flag
+# Enable manual analytics trigger button in call log details
+# Set to 'true' to show the "Run Analytics" / "Retry Analytics" button
+VITE_ENABLE_MANUAL_ANALYTICS=false

+ 2 - 9
shopcall.ai-main/src/App.tsx

@@ -9,7 +9,7 @@ import { AuthProvider } from "./components/context/AuthContext";
 import { ShopProvider } from "./components/context/ShopContext";
 import PrivateRoute from "./components/PrivateRoute";
 import StoreProtectedRoute from "./components/StoreProtectedRoute";
-import { Loader2 } from "lucide-react";
+import { LoadingScreen } from "@/components/ui/loading-screen";
 
 // Lazy load page components for code splitting
 // Critical pages (loaded immediately)
@@ -43,14 +43,7 @@ const CustomContent = lazy(() => import("./pages/CustomContent"));
 const Billing = lazy(() => import("./pages/Billing"));
 
 // Loading component for lazy-loaded routes
-const PageLoader = () => (
-  <div className="flex items-center justify-center min-h-screen bg-slate-900">
-    <div className="flex items-center gap-2 text-white">
-      <Loader2 className="h-6 w-6 animate-spin" />
-      <span>Loading...</span>
-    </div>
-  </div>
-);
+const PageLoader = () => <LoadingScreen />;
 
 const queryClient = new QueryClient();
 

+ 3 - 6
shopcall.ai-main/src/components/AIConfigContent.tsx

@@ -5,12 +5,13 @@ import { Label } from "@/components/ui/label";
 import { Textarea } from "@/components/ui/textarea";
 import { Switch } from "@/components/ui/switch";
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Mic, MessageSquare, Store, Loader2 } from "lucide-react";
+import { Mic, MessageSquare, Store, Loader2, Bot } from "lucide-react";
 import { useState, useEffect } from "react";
 import { API_URL } from "@/lib/config";
 import { useToast } from "@/hooks/use-toast";
 import { useTranslation } from "react-i18next";
 import { useShop } from "@/components/context/ShopContext";
+import { LoadingScreen } from "@/components/ui/loading-screen";
 
 interface AIConfig {
   voice_type: string;
@@ -102,11 +103,7 @@ export function AIConfigContent() {
   };
 
   if (isLoading) {
-    return (
-      <div className="flex-1 flex items-center justify-center min-h-screen bg-slate-900">
-        <Loader2 className="w-8 h-8 text-cyan-500 animate-spin" />
-      </div>
-    );
+    return <LoadingScreen icon={Bot} message={t('common.loading')} />;
   }
 
   if (stores.length === 0) {

+ 501 - 181
shopcall.ai-main/src/components/CallLogDetailContent.tsx

@@ -2,10 +2,12 @@ import { useState, useEffect, useRef } from "react";
 import { useParams, useNavigate } from "react-router-dom";
 import { Button } from "@/components/ui/button";
 import { Card } from "@/components/ui/card";
-import { ArrowLeft, Phone, Clock, Calendar, Bot, User, Play, Pause } from "lucide-react";
+import { ArrowLeft, Phone, Clock, Calendar, X, Play, Pause, Volume2, RefreshCw, User, ShoppingCart, Package, AlertCircle, CheckCircle, Loader2 } from "lucide-react";
 import { useTranslation } from "react-i18next";
 import { supabase } from "@/lib/supabase";
-import { API_URL } from "@/lib/config";
+import { API_URL, ENABLE_MANUAL_ANALYTICS } from "@/lib/config";
+import ReactMarkdown from 'react-markdown';
+import { AnalyticsStatus, ANALYTICS_STATUS, RelatedCustomer, RelatedOrder, RelatedProduct } from "@/types/database.types";
 
 interface CallLog {
   id: string;
@@ -20,12 +22,26 @@ interface CallLog {
   costs: any | null;
   cost_total: number | null;
   payload: any;
+  outcome: string | null;
+  summary: string | null;
+  satisfaction_score: number | null;
+  related_customers: RelatedCustomer[] | null;
+  related_orders: RelatedOrder[] | null;
+  related_products: RelatedProduct[] | null;
+  analytics_status: AnalyticsStatus | null;
+  analytics_error: string | null;
 }
 
-interface TranscriptMessage {
-  role: 'ai' | 'user';
-  content: string;
-}
+// Helper function to get satisfaction emoji and color based on score
+const getSatisfactionDisplay = (score: number | null): { emoji: string; label: string; color: string } => {
+  if (score === null) return { emoji: '❓', label: 'Unknown', color: 'text-slate-400' };
+
+  if (score >= 81) return { emoji: '😍', label: 'Very Satisfied', color: 'text-green-400' };
+  if (score >= 61) return { emoji: '😊', label: 'Satisfied', color: 'text-lime-400' };
+  if (score >= 41) return { emoji: '😐', label: 'Neutral', color: 'text-yellow-400' };
+  if (score >= 21) return { emoji: '😞', label: 'Dissatisfied', color: 'text-orange-400' };
+  return { emoji: '😠', label: 'Very Dissatisfied', color: 'text-red-400' };
+};
 
 const formatDuration = (seconds: number | null): string => {
   if (!seconds) return "-";
@@ -34,41 +50,78 @@ const formatDuration = (seconds: number | null): string => {
   return `${mins}:${secs.toString().padStart(2, '0')}`;
 };
 
-const formatDateTime = (dateStr: string | null): string => {
+const getRelativeTime = (dateStr: string | null, t: (key: string, options?: any) => string): string => {
   if (!dateStr) return "-";
   const date = new Date(dateStr);
-  return date.toLocaleString();
+  const now = new Date();
+  const diffMs = now.getTime() - date.getTime();
+  const diffMins = Math.floor(diffMs / 60000);
+  const diffHours = Math.floor(diffMins / 60);
+  const diffDays = Math.floor(diffHours / 24);
+
+  if (diffMins < 1) return t('common.justNow');
+  if (diffMins < 60) return t('common.minsAgo', { count: diffMins });
+  if (diffHours < 24) return t('common.hoursAgo', { count: diffHours });
+  return t('common.daysAgo', { count: diffDays });
 };
 
-const parseTranscript = (transcript: string | null): TranscriptMessage[] => {
-  if (!transcript) return [];
+const getOutcomeColor = (outcome: string | null): string => {
+  if (!outcome) return "bg-slate-600";
+
+  const colorMap: Record<string, string> = {
+    resolved: "bg-green-600",
+    order_placed: "bg-green-600",
+    order_inquiry: "bg-blue-600",
+    product_inquiry: "bg-blue-600",
+    general_question: "bg-blue-600",
+    complaint: "bg-red-600",
+    return_request: "bg-orange-600",
+    callback_requested: "bg-purple-600",
+    escalated: "bg-yellow-600",
+    no_answer: "bg-slate-500",
+    voicemail: "bg-slate-500",
+    wrong_number: "bg-slate-500",
+    abandoned: "bg-slate-500",
+  };
 
-  const messages: TranscriptMessage[] = [];
-  const lines = transcript.split('\n');
+  return colorMap[outcome] || "bg-slate-600";
+};
 
-  for (const line of lines) {
-    const trimmedLine = line.trim();
-    if (!trimmedLine) continue;
+const formatOutcome = (outcome: string | null): string => {
+  if (!outcome) return "-";
+  return outcome.split('_').map(word =>
+    word.charAt(0).toUpperCase() + word.slice(1)
+  ).join(' ');
+};
 
-    if (trimmedLine.startsWith('AI:')) {
-      messages.push({
-        role: 'ai',
-        content: trimmedLine.substring(3).trim()
-      });
-    } else if (trimmedLine.startsWith('User:')) {
-      messages.push({
-        role: 'user',
-        content: trimmedLine.substring(5).trim()
-      });
-    } else {
-      // If line doesn't have a prefix, append to last message or create new one
-      if (messages.length > 0) {
-        messages[messages.length - 1].content += ' ' + trimmedLine;
-      }
+const formatTranscript = (transcript: string | null): React.ReactNode => {
+  if (!transcript) return null;
+
+  // Split by newlines and process each line
+  const lines = transcript.split('\n').filter(line => line.trim());
+
+  return lines.map((line, index) => {
+    // Remove AI:, User:, Assistant:, Customer: prefixes (case insensitive)
+    let cleanLine = line.replace(/^(AI|User|Assistant|Customer|Bot|Agent):\s*/i, '').trim();
+
+    // Determine if this was an AI/assistant line or user/customer line
+    const isAI = /^(AI|Assistant|Bot|Agent):/i.test(line);
+    const isUser = /^(User|Customer):/i.test(line);
+
+    // Add emoji based on speaker
+    let emoji = '';
+    if (isAI) {
+      emoji = '🤖 ';
+    } else if (isUser) {
+      emoji = '👤 ';
     }
-  }
 
-  return messages;
+    return (
+      <p key={index} className={`text-slate-300 text-sm ${index > 0 ? 'mt-2' : ''}`}>
+        {emoji}{cleanLine}
+      </p>
+    );
+  });
 };
 
 export function CallLogDetailContent() {
@@ -79,8 +132,75 @@ export function CallLogDetailContent() {
   const [isLoading, setIsLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [isPlaying, setIsPlaying] = useState(false);
+  const [currentTime, setCurrentTime] = useState(0);
+  const [isAnalyticsTriggering, setIsAnalyticsTriggering] = useState(false);
   const audioRef = useRef<HTMLAudioElement>(null);
 
+  const handleTriggerAnalytics = async () => {
+    if (!id || !callLog) return;
+
+    setIsAnalyticsTriggering(true);
+    try {
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) {
+        throw new Error('Not authenticated');
+      }
+
+      const response = await fetch(`${API_URL}/api/call-logs/${id}/trigger-analytics`, {
+        method: 'POST',
+        headers: {
+          'Authorization': `Bearer ${session.access_token}`,
+          'Content-Type': 'application/json',
+        },
+      });
+
+      if (!response.ok) {
+        const errorData = await response.json();
+        throw new Error(errorData.error || 'Failed to trigger analytics');
+      }
+
+      // Update local state to show running status
+      setCallLog({
+        ...callLog,
+        analytics_status: ANALYTICS_STATUS.RUNNING,
+        analytics_error: null,
+      });
+
+      // Start polling for updates
+      const pollInterval = setInterval(async () => {
+        const pollResponse = await fetch(`${API_URL}/api/call-logs/${id}`, {
+          headers: {
+            'Authorization': `Bearer ${session.access_token}`,
+            'Content-Type': 'application/json',
+          },
+        });
+
+        if (pollResponse.ok) {
+          const data = await pollResponse.json();
+          setCallLog(data.call_log);
+
+          // Stop polling if completed or failed
+          if (data.call_log.analytics_status !== ANALYTICS_STATUS.RUNNING) {
+            clearInterval(pollInterval);
+          }
+        }
+      }, 3000); // Poll every 3 seconds
+
+      // Stop polling after 2 minutes max
+      setTimeout(() => clearInterval(pollInterval), 120000);
+
+    } catch (err) {
+      console.error('Failed to trigger analytics:', err);
+      setCallLog({
+        ...callLog,
+        analytics_status: ANALYTICS_STATUS.FAILED,
+        analytics_error: err instanceof Error ? err.message : 'Failed to trigger analytics',
+      });
+    } finally {
+      setIsAnalyticsTriggering(false);
+    }
+  };
+
   useEffect(() => {
     const fetchCallLog = async () => {
       if (!id) return;
@@ -135,9 +255,14 @@ export function CallLogDetailContent() {
 
   const handleAudioEnded = () => {
     setIsPlaying(false);
+    setCurrentTime(0);
   };
 
-  const messages = parseTranscript(callLog?.transcript || null);
+  const handleTimeUpdate = () => {
+    if (audioRef.current) {
+      setCurrentTime(audioRef.current.currentTime);
+    }
+  };
 
   if (isLoading) {
     return (
@@ -179,202 +304,397 @@ export function CallLogDetailContent() {
 
   return (
     <div className="flex-1 bg-slate-900 text-white overflow-hidden flex flex-col">
-      {/* Header */}
-      <div className="border-b border-slate-800 bg-slate-900">
-        <div className="flex items-center gap-4 p-6">
+      {/* Modal Header */}
+      <div className="border-b border-slate-800 bg-slate-900 p-6">
+        <div className="flex items-center justify-between">
+          <h1 className="text-xl font-semibold text-white">{t('callLogDetail.title')}</h1>
           <Button
             variant="ghost"
             size="sm"
             className="text-slate-400 hover:text-white"
             onClick={handleBack}
           >
-            <ArrowLeft className="w-4 h-4 mr-2" />
-            {t('common.back')}
+            <X className="w-5 h-5" />
           </Button>
-          <div>
-            <h1 className="text-2xl font-semibold text-white">
-              {t('callLogDetail.title')}{callLog.caller ? ` - ${callLog.caller}` : ''}
-            </h1>
-            <p className="text-slate-400">{formatDateTime(callLog.started_at)}</p>
-          </div>
         </div>
       </div>
 
       {/* Content - Two Column Layout */}
-      <div className="flex-1 overflow-hidden p-6">
-        <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-full">
-          {/* Left Column - Call Statistics */}
-          <div className="lg:col-span-1 space-y-6">
-            {/* Call Info Card */}
-            <Card className="bg-slate-800 border-slate-700 p-6">
-              <h3 className="text-lg font-semibold text-white mb-4">{t('callLogDetail.callInfo')}</h3>
-
-              <div className="space-y-4">
-                <div className="flex items-center gap-3">
-                  <div className="w-10 h-10 bg-cyan-500/10 rounded-lg flex items-center justify-center">
-                    <Calendar className="w-5 h-5 text-cyan-400" />
-                  </div>
-                  <div>
-                    <p className="text-sm text-slate-400">{t('callLogDetail.startedAt')}</p>
-                    <p className="text-white">{formatDateTime(callLog.started_at)}</p>
-                  </div>
-                </div>
+      <div className="flex-1 overflow-y-auto p-6">
+        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+          {/* Left Column - Contact Information */}
+          <Card className="bg-slate-800 border-slate-700 p-6">
+            <div className="flex items-center gap-2 mb-6">
+              <Phone className="w-5 h-5 text-slate-400" />
+              <h3 className="text-lg font-semibold text-white">{t('callLogDetail.contactInfo')}</h3>
+            </div>
+
+            {/* Phone Number with Badge */}
+            <div className="mb-4">
+              <p className="text-slate-400 text-sm mb-1">{t('callLogDetail.caller')}</p>
+              <div className="flex items-center gap-2">
+                <span className="text-white font-medium">{callLog.caller || '-'}</span>
+                {callLog.outcome && (
+                  <span className={`px-2 py-0.5 rounded text-xs font-medium text-white ${getOutcomeColor(callLog.outcome)}`}>
+                    {formatOutcome(callLog.outcome)}
+                  </span>
+                )}
+              </div>
+            </div>
+
+            {/* Phone with icon */}
+            <div className="flex items-center gap-2 mb-2">
+              <Phone className="w-4 h-4 text-slate-400" />
+              <span className="text-slate-300">{callLog.caller || '-'}</span>
+            </div>
+
+            {/* Added time */}
+            <div className="flex items-center gap-2 mb-6">
+              <Calendar className="w-4 h-4 text-slate-400" />
+              <span className="text-slate-300">{getRelativeTime(callLog.created_at, t)}</span>
+            </div>
+
+            {/* Stats Grid - 2x3 */}
+            <div className="grid grid-cols-2 gap-4 mb-6">
+              {/* Total Calls */}
+              <div className="bg-slate-700/50 rounded-lg p-4 text-center">
+                <div className="text-2xl font-bold text-white">1</div>
+                <div className="text-sm text-slate-400">{t('callLogDetail.totalCalls')}</div>
+              </div>
 
-                <div className="flex items-center gap-3">
-                  <div className="w-10 h-10 bg-cyan-500/10 rounded-lg flex items-center justify-center">
-                    <Calendar className="w-5 h-5 text-cyan-400" />
-                  </div>
-                  <div>
-                    <p className="text-sm text-slate-400">{t('callLogDetail.endedAt')}</p>
-                    <p className="text-white">{formatDateTime(callLog.ended_at)}</p>
-                  </div>
-                </div>
+              {/* Total Duration */}
+              <div className="bg-slate-700/50 rounded-lg p-4 text-center">
+                <div className="text-2xl font-bold text-white">{formatDuration(callLog.duration)}</div>
+                <div className="text-sm text-slate-400">{t('callLogDetail.totalDuration')}</div>
+              </div>
 
-                <div className="flex items-center gap-3">
-                  <div className="w-10 h-10 bg-green-500/10 rounded-lg flex items-center justify-center">
-                    <Phone className="w-5 h-5 text-green-400" />
-                  </div>
-                  <div>
-                    <p className="text-sm text-slate-400">{t('callLogDetail.caller')}</p>
-                    <p className="text-white">{callLog.caller || '-'}</p>
-                  </div>
+              {/* Total Cost */}
+              <div className="bg-slate-700/50 rounded-lg p-4 text-center">
+                <div className="text-2xl font-bold text-white">
+                  {callLog.cost_total ? `$${callLog.cost_total.toFixed(2)}` : '-'}
                 </div>
+                <div className="text-sm text-slate-400">{t('callLogDetail.totalCost')}</div>
+              </div>
 
-                <div className="flex items-center gap-3">
-                  <div className="w-10 h-10 bg-purple-500/10 rounded-lg flex items-center justify-center">
-                    <Clock className="w-5 h-5 text-purple-400" />
-                  </div>
-                  <div>
-                    <p className="text-sm text-slate-400">{t('callLogDetail.duration')}</p>
-                    <p className="text-white">{formatDuration(callLog.duration)}</p>
-                  </div>
-                </div>
+              {/* Outcome in grid */}
+              <div className="bg-slate-700/50 rounded-lg p-4 text-center">
+                <div className="text-lg font-bold text-white">{formatOutcome(callLog.outcome)}</div>
+                <div className="text-sm text-slate-400">{t('callLogDetail.callOutcome')}</div>
+              </div>
 
-                {/* Cost item hidden - cost calculation not fully implemented
-                <div className="flex items-center gap-3">
-                  <div className="w-10 h-10 bg-yellow-500/10 rounded-lg flex items-center justify-center">
-                    <DollarSign className="w-5 h-5 text-yellow-400" />
-                  </div>
-                  <div>
-                    <p className="text-sm text-slate-400">{t('callLogDetail.cost')}</p>
-                    <p className="text-white">
-                      {callLog.cost_total ? `$${callLog.cost_total.toFixed(4)}` : '-'}
-                    </p>
+              {/* Customer Satisfaction */}
+              <div className="bg-slate-700/50 rounded-lg p-4 text-center col-span-2">
+                {(() => {
+                  const satisfaction = getSatisfactionDisplay(callLog.satisfaction_score);
+                  return (
+                    <>
+                      <div className="flex items-center justify-center gap-2">
+                        <span className="text-4xl">{satisfaction.emoji}</span>
+                        <div className="text-left">
+                          <div className={`text-2xl font-bold ${satisfaction.color}`}>
+                            {callLog.satisfaction_score !== null ? `${callLog.satisfaction_score}%` : '-'}
+                          </div>
+                          <div className={`text-sm ${satisfaction.color}`}>{satisfaction.label}</div>
+                        </div>
+                      </div>
+                      <div className="text-sm text-slate-400 mt-1">{t('callLogDetail.satisfaction')}</div>
+                    </>
+                  );
+                })()}
+              </div>
+            </div>
+
+            {/* Call Outcome Box */}
+            <div className="bg-slate-700/50 rounded-lg p-4 text-center">
+              <div className={`inline-block px-4 py-2 rounded-lg text-lg font-bold text-white ${getOutcomeColor(callLog.outcome)}`}>
+                {formatOutcome(callLog.outcome)}
+              </div>
+              <div className="text-sm text-slate-400 mt-2">{t('callLogDetail.callOutcome')}</div>
+            </div>
+          </Card>
+
+          {/* Right Column - Call History */}
+          <Card className="bg-slate-800 border-slate-700 p-6">
+            <div className="flex items-center gap-2 mb-6">
+              <Calendar className="w-5 h-5 text-slate-400" />
+              <h3 className="text-lg font-semibold text-white">{t('callLogDetail.callHistory')}</h3>
+            </div>
+
+            {/* Call Header */}
+            <div className="flex items-center justify-between mb-6">
+              <div className="flex items-center gap-2">
+                <Calendar className="w-4 h-4 text-slate-400" />
+                <span className="text-slate-300">{getRelativeTime(callLog.started_at, t)}</span>
+              </div>
+              <div className="flex items-center gap-3">
+                {callLog.outcome && (
+                  <span className={`px-2 py-0.5 rounded text-xs font-medium text-white ${getOutcomeColor(callLog.outcome)}`}>
+                    {formatOutcome(callLog.outcome)}
+                  </span>
+                )}
+                <span className="text-slate-300 flex items-center gap-1">
+                  <Clock className="w-4 h-4" />
+                  {formatDuration(callLog.duration)}
+                </span>
+              </div>
+            </div>
+
+            {/* Transcript */}
+            <div className="mb-6">
+              <h4 className="text-white font-medium mb-2">{t('callLogDetail.transcript')}</h4>
+              <div className="bg-slate-700/30 rounded-lg p-4 max-h-48 overflow-y-auto">
+                {callLog.transcript ? (
+                  formatTranscript(callLog.transcript)
+                ) : (
+                  <p className="text-slate-400 text-sm">{t('callLogDetail.noTranscript')}</p>
+                )}
+              </div>
+            </div>
+
+            {/* Summary */}
+            <div className="mb-6">
+              <h4 className="text-white font-medium mb-2">{t('callLogDetail.summary')}</h4>
+              <div className="bg-teal-900/30 border border-teal-700/50 rounded-lg p-4">
+                {callLog.summary ? (
+                  <div className="text-slate-200 text-sm prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-li:my-0">
+                    <ReactMarkdown>{callLog.summary}</ReactMarkdown>
                   </div>
-                </div>
-                */}
+                ) : (
+                  <p className="text-slate-400 text-sm">-</p>
+                )}
               </div>
-            </Card>
+            </div>
+
+            {/* Call Outcome */}
+            <div className="mb-6">
+              <div className="flex items-center gap-2">
+                <span className="text-slate-400">{t('callLogDetail.callOutcome')}:</span>
+                <span className={`px-2 py-0.5 rounded text-sm font-medium text-white ${getOutcomeColor(callLog.outcome)}`}>
+                  {formatOutcome(callLog.outcome)}
+                </span>
+              </div>
+            </div>
 
-            {/* Audio Player Card */}
+            {/* Call Recording */}
             {callLog.recording_url && (
-              <Card className="bg-slate-800 border-slate-700 p-6">
-                <h3 className="text-lg font-semibold text-white mb-4">{t('callLogDetail.recording')}</h3>
-
-                <div className="space-y-4">
+              <div className="mb-6">
+                <h4 className="text-white font-medium mb-2">{t('callLogDetail.recording')}</h4>
+                <div className="bg-slate-700/30 rounded-lg p-4">
                   <audio
                     ref={audioRef}
                     src={callLog.recording_url}
                     onEnded={handleAudioEnded}
+                    onTimeUpdate={handleTimeUpdate}
                     className="hidden"
                   />
-
                   <div className="flex items-center gap-4">
                     <Button
                       onClick={togglePlayPause}
-                      className="w-12 h-12 rounded-full bg-cyan-600 hover:bg-cyan-500 flex items-center justify-center"
+                      size="sm"
+                      className="w-10 h-10 rounded-full bg-slate-600 hover:bg-slate-500 flex items-center justify-center p-0"
                     >
                       {isPlaying ? (
-                        <Pause className="w-5 h-5" />
+                        <Pause className="w-4 h-4" />
                       ) : (
-                        <Play className="w-5 h-5 ml-0.5" />
+                        <Play className="w-4 h-4 ml-0.5" />
                       )}
                     </Button>
                     <div className="flex-1">
-                      <p className="text-sm text-slate-400">{t('callLogDetail.clickToPlay')}</p>
-                      <p className="text-white text-sm">{formatDuration(callLog.duration)}</p>
+                      <div className="flex items-center gap-2 text-sm text-slate-400">
+                        <span>{formatDuration(Math.floor(currentTime))}</span>
+                        <div className="flex-1 h-1 bg-slate-600 rounded-full overflow-hidden">
+                          <div
+                            className="h-full bg-cyan-500 transition-all"
+                            style={{
+                              width: callLog.duration
+                                ? `${(currentTime / callLog.duration) * 100}%`
+                                : '0%'
+                            }}
+                          />
+                        </div>
+                        <span>{formatDuration(callLog.duration)}</span>
+                        <Volume2 className="w-4 h-4" />
+                      </div>
                     </div>
                   </div>
-
-                  {/* Native audio controls as fallback */}
-                  <audio
-                    src={callLog.recording_url}
-                    controls
-                    className="w-full mt-2"
-                  />
                 </div>
-              </Card>
+              </div>
             )}
 
-            {/* Costs Breakdown Card - hidden, cost calculation not fully implemented
-            {callLog.costs && Array.isArray(callLog.costs) && callLog.costs.length > 0 && (
-              <Card className="bg-slate-800 border-slate-700 p-6">
-                <h3 className="text-lg font-semibold text-white mb-4">{t('callLogDetail.costsBreakdown')}</h3>
-
-                <div className="space-y-2">
-                  {callLog.costs.map((cost: any, index: number) => (
-                    <div key={index} className="flex justify-between items-center py-2 border-b border-slate-700 last:border-0">
-                      <span className="text-slate-400 text-sm capitalize">{cost.type}</span>
-                      <span className="text-white text-sm">${cost.cost?.toFixed(4) || '0.0000'}</span>
-                    </div>
-                  ))}
-                  <div className="flex justify-between items-center pt-2 border-t border-slate-600">
-                    <span className="text-white font-medium">{t('callLogDetail.total')}</span>
-                    <span className="text-cyan-400 font-medium">
-                      ${callLog.cost_total?.toFixed(4) || '0.0000'}
-                    </span>
-                  </div>
+            {/* Analytics Status Banner */}
+            {callLog.analytics_status && callLog.analytics_status !== ANALYTICS_STATUS.COMPLETED && (
+              <div className={`mb-6 p-4 rounded-lg flex items-center justify-between ${
+                callLog.analytics_status === ANALYTICS_STATUS.RUNNING
+                  ? 'bg-blue-900/30 border border-blue-700/50'
+                  : callLog.analytics_status === ANALYTICS_STATUS.FAILED
+                    ? 'bg-red-900/30 border border-red-700/50'
+                    : 'bg-slate-700/30 border border-slate-600/50'
+              }`}>
+                <div className="flex items-center gap-3">
+                  {callLog.analytics_status === ANALYTICS_STATUS.RUNNING && (
+                    <>
+                      <Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
+                      <span className="text-blue-300">{t('callLogs.analyticsStatus.running')}</span>
+                    </>
+                  )}
+                  {callLog.analytics_status === ANALYTICS_STATUS.FAILED && (
+                    <>
+                      <AlertCircle className="w-5 h-5 text-red-400" />
+                      <div>
+                        <span className="text-red-300">{t('callLogs.analyticsStatus.failed')}</span>
+                        {callLog.analytics_error && (
+                          <p className="text-xs text-red-400 mt-1">{callLog.analytics_error}</p>
+                        )}
+                      </div>
+                    </>
+                  )}
+                  {callLog.analytics_status === ANALYTICS_STATUS.PENDING && (
+                    <>
+                      <Clock className="w-5 h-5 text-slate-400" />
+                      <span className="text-slate-300">{t('callLogs.analyticsStatus.pending')}</span>
+                    </>
+                  )}
                 </div>
-              </Card>
-            )}
-            */}
-          </div>
 
-          {/* Right Column - Transcript Chat */}
-          <div className="lg:col-span-2 h-full">
-            <Card className="bg-slate-800 border-slate-700 h-full flex flex-col">
-              <div className="p-4 border-b border-slate-700">
-                <h3 className="text-lg font-semibold text-white">{t('callLogDetail.transcript')}</h3>
+                {/* Retry/Run Analytics Button */}
+                {ENABLE_MANUAL_ANALYTICS && callLog.transcript && (
+                  callLog.analytics_status === ANALYTICS_STATUS.FAILED ||
+                  callLog.analytics_status === ANALYTICS_STATUS.PENDING
+                ) && (
+                  <Button
+                    onClick={handleTriggerAnalytics}
+                    disabled={isAnalyticsTriggering}
+                    size="sm"
+                    className="bg-slate-600 hover:bg-slate-500"
+                  >
+                    {isAnalyticsTriggering ? (
+                      <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                    ) : (
+                      <RefreshCw className="w-4 h-4 mr-2" />
+                    )}
+                    {callLog.analytics_status === ANALYTICS_STATUS.FAILED
+                      ? t('callLogs.retryAnalytics')
+                      : t('callLogs.runAnalytics')
+                    }
+                  </Button>
+                )}
               </div>
+            )}
 
-              <div className="flex-1 overflow-y-auto p-4 space-y-4">
-                {messages.length === 0 ? (
-                  <div className="flex items-center justify-center h-full">
-                    <p className="text-slate-400">{t('callLogDetail.noTranscript')}</p>
+            {/* Related Entities */}
+            <div className="space-y-4">
+              {/* Related Customers */}
+              {callLog.related_customers && callLog.related_customers.length > 0 && (
+                <div>
+                  <h4 className="text-white font-medium mb-2 flex items-center gap-2">
+                    <User className="w-4 h-4 text-slate-400" />
+                    {t('callLogDetail.relatedCustomers')} ({callLog.related_customers.length})
+                  </h4>
+                  <div className="space-y-2">
+                    {callLog.related_customers.map((customer, idx) => (
+                      <div key={idx} className="bg-slate-700/30 rounded-lg p-3">
+                        <div className="flex items-center justify-between">
+                          <div>
+                            {customer.name && (
+                              <p className="text-white font-medium">{customer.name}</p>
+                            )}
+                            {customer.email && (
+                              <p className="text-slate-300 text-sm">{customer.email}</p>
+                            )}
+                            {customer.phone && (
+                              <p className="text-slate-400 text-sm">{customer.phone}</p>
+                            )}
+                          </div>
+                        </div>
+                        <p className="text-slate-400 text-xs mt-2 italic">{customer.match_reason}</p>
+                      </div>
+                    ))}
                   </div>
-                ) : (
-                  messages.map((message, index) => (
-                    <div
-                      key={index}
-                      className={`flex gap-3 ${message.role === 'ai' ? 'justify-start' : 'justify-end'}`}
-                    >
-                      {message.role === 'ai' && (
-                        <div className="w-8 h-8 bg-cyan-500/20 rounded-full flex items-center justify-center flex-shrink-0">
-                          <Bot className="w-4 h-4 text-cyan-400" />
+                </div>
+              )}
+
+              {/* Related Orders */}
+              {callLog.related_orders && callLog.related_orders.length > 0 && (
+                <div>
+                  <h4 className="text-white font-medium mb-2 flex items-center gap-2">
+                    <ShoppingCart className="w-4 h-4 text-slate-400" />
+                    {t('callLogDetail.relatedOrders')} ({callLog.related_orders.length})
+                  </h4>
+                  <div className="space-y-2">
+                    {callLog.related_orders.map((order, idx) => (
+                      <div key={idx} className="bg-slate-700/30 rounded-lg p-3">
+                        <div className="flex items-center justify-between">
+                          <div>
+                            <p className="text-white font-medium">#{order.order_number}</p>
+                            {order.status && (
+                              <span className="text-xs px-2 py-0.5 bg-slate-600 rounded text-slate-300">
+                                {order.status}
+                              </span>
+                            )}
+                          </div>
+                          {order.total && (
+                            <p className="text-green-400 font-medium">{order.total}</p>
+                          )}
                         </div>
-                      )}
-
-                      <div
-                        className={`max-w-[80%] rounded-2xl px-4 py-3 ${
-                          message.role === 'ai'
-                            ? 'bg-slate-700 text-white rounded-tl-none'
-                            : 'bg-cyan-600 text-white rounded-tr-none'
-                        }`}
-                      >
-                        <p className="text-sm leading-relaxed">{message.content}</p>
+                        <p className="text-slate-400 text-xs mt-2 italic">{order.match_reason}</p>
                       </div>
-
-                      {message.role === 'user' && (
-                        <div className="w-8 h-8 bg-green-500/20 rounded-full flex items-center justify-center flex-shrink-0">
-                          <User className="w-4 h-4 text-green-400" />
+                    ))}
+                  </div>
+                </div>
+              )}
+
+              {/* Related Products */}
+              {callLog.related_products && callLog.related_products.length > 0 && (
+                <div>
+                  <h4 className="text-white font-medium mb-2 flex items-center gap-2">
+                    <Package className="w-4 h-4 text-slate-400" />
+                    {t('callLogDetail.relatedProducts')} ({callLog.related_products.length})
+                  </h4>
+                  <div className="space-y-2">
+                    {callLog.related_products.map((product, idx) => (
+                      <div key={idx} className="bg-slate-700/30 rounded-lg p-3">
+                        <div className="flex items-center justify-between">
+                          <div>
+                            <p className="text-white font-medium">{product.name}</p>
+                            {product.sku && (
+                              <p className="text-slate-400 text-sm">SKU: {product.sku}</p>
+                            )}
+                          </div>
+                          {product.price && (
+                            <p className="text-green-400 font-medium">{product.price}</p>
+                          )}
                         </div>
-                      )}
-                    </div>
-                  ))
-                )}
-              </div>
-            </Card>
-          </div>
+                        <p className="text-slate-400 text-xs mt-2 italic">{product.match_reason}</p>
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              )}
+
+              {/* Show "Run Analytics" button if no related data and analytics not completed */}
+              {ENABLE_MANUAL_ANALYTICS && callLog.transcript &&
+                !callLog.related_customers?.length &&
+                !callLog.related_orders?.length &&
+                !callLog.related_products?.length &&
+                callLog.analytics_status !== ANALYTICS_STATUS.RUNNING && (
+                <div className="text-center py-4">
+                  <p className="text-slate-400 text-sm mb-3">{t('callLogDetail.noRelatedData')}</p>
+                  <Button
+                    onClick={handleTriggerAnalytics}
+                    disabled={isAnalyticsTriggering}
+                    className="bg-cyan-600 hover:bg-cyan-500"
+                  >
+                    {isAnalyticsTriggering ? (
+                      <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                    ) : (
+                      <RefreshCw className="w-4 h-4 mr-2" />
+                    )}
+                    {t('callLogs.runAnalytics')}
+                  </Button>
+                </div>
+              )}
+            </div>
+          </Card>
         </div>
       </div>
     </div>

+ 127 - 17
shopcall.ai-main/src/components/CallLogsContent.tsx

@@ -1,7 +1,7 @@
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Card } from "@/components/ui/card";
-import { Search, Download, Calendar, Filter, ChevronRight } from "lucide-react";
+import { Search, Download, Calendar, Filter, ChevronRight, Phone, Clock, CheckCircle, XCircle, Loader2 } from "lucide-react";
 import { useState, useEffect } from "react";
 import { useNavigate } from "react-router-dom";
 import { ExportModal, ExportFormat } from "./ExportModal";
@@ -9,6 +9,14 @@ import { exportCallLogs } from "@/lib/exportUtils";
 import { useTranslation } from "react-i18next";
 import { supabase } from "@/lib/supabase";
 import { API_URL } from "@/lib/config";
+import { LoadingScreen } from "@/components/ui/loading-screen";
+import { AnalyticsStatus, ANALYTICS_STATUS } from "@/types/database.types";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
 
 interface CallLog {
   id: string;
@@ -23,8 +31,98 @@ interface CallLog {
   costs: any | null;
   cost_total: number | null;
   payload: any;
+  outcome: string | null;
+  analytics_status: AnalyticsStatus | null;
+  analytics_error: string | null;
 }
 
+// Analytics status indicator component
+const AnalyticsStatusIndicator = ({ status, error, t }: { status: AnalyticsStatus | null; error: string | null; t: (key: string) => string }) => {
+  if (!status || status === ANALYTICS_STATUS.PENDING) {
+    return (
+      <Tooltip>
+        <TooltipTrigger>
+          <Clock className="w-4 h-4 text-slate-400" />
+        </TooltipTrigger>
+        <TooltipContent>
+          <p>{t('callLogs.analyticsStatus.pending')}</p>
+        </TooltipContent>
+      </Tooltip>
+    );
+  }
+
+  if (status === ANALYTICS_STATUS.RUNNING) {
+    return (
+      <Tooltip>
+        <TooltipTrigger>
+          <Loader2 className="w-4 h-4 text-blue-400 animate-spin" />
+        </TooltipTrigger>
+        <TooltipContent>
+          <p>{t('callLogs.analyticsStatus.running')}</p>
+        </TooltipContent>
+      </Tooltip>
+    );
+  }
+
+  if (status === ANALYTICS_STATUS.COMPLETED) {
+    return (
+      <Tooltip>
+        <TooltipTrigger>
+          <CheckCircle className="w-4 h-4 text-green-400" />
+        </TooltipTrigger>
+        <TooltipContent>
+          <p>{t('callLogs.analyticsStatus.completed')}</p>
+        </TooltipContent>
+      </Tooltip>
+    );
+  }
+
+  if (status === ANALYTICS_STATUS.FAILED) {
+    return (
+      <Tooltip>
+        <TooltipTrigger>
+          <XCircle className="w-4 h-4 text-red-400" />
+        </TooltipTrigger>
+        <TooltipContent>
+          <p>{t('callLogs.analyticsStatus.failed')}</p>
+          {error && <p className="text-xs text-red-300 mt-1">{error}</p>}
+        </TooltipContent>
+      </Tooltip>
+    );
+  }
+
+  return null;
+};
+
+const getOutcomeColor = (outcome: string | null): string => {
+  if (!outcome) return "bg-slate-600";
+
+  const colorMap: Record<string, string> = {
+    resolved: "bg-green-600",
+    order_placed: "bg-green-600",
+    order_inquiry: "bg-blue-600",
+    product_inquiry: "bg-blue-600",
+    general_question: "bg-blue-600",
+    complaint: "bg-red-600",
+    return_request: "bg-orange-600",
+    callback_requested: "bg-purple-600",
+    escalated: "bg-yellow-600",
+    no_answer: "bg-slate-500",
+    voicemail: "bg-slate-500",
+    wrong_number: "bg-slate-500",
+    abandoned: "bg-slate-500",
+  };
+
+  return colorMap[outcome] || "bg-slate-600";
+};
+
+const formatOutcome = (outcome: string | null): string => {
+  if (!outcome) return "-";
+  return outcome.split('_').map(word =>
+    word.charAt(0).toUpperCase() + word.slice(1)
+  ).join(' ');
+};
+
 const formatDuration = (seconds: number | null): string => {
   if (!seconds) return "-";
   const mins = Math.floor(seconds / 60);
@@ -38,13 +136,6 @@ const formatDateTime = (dateStr: string | null): string => {
   return date.toLocaleString();
 };
 
-const maskPhoneNumber = (phone: string | null): string => {
-  if (!phone) return "-";
-  if (phone.length <= 4) return phone;
-  const last4 = phone.slice(-4);
-  return `***${last4}`;
-};
-
 export function CallLogsContent() {
   const { t } = useTranslation();
   const navigate = useNavigate();
@@ -118,7 +209,8 @@ export function CallLogsContent() {
     const searchLower = searchQuery.toLowerCase();
     return (
       (log.caller && log.caller.toLowerCase().includes(searchLower)) ||
-      (log.transcript && log.transcript.toLowerCase().includes(searchLower))
+      (log.transcript && log.transcript.toLowerCase().includes(searchLower)) ||
+      (log.outcome && formatOutcome(log.outcome).toLowerCase().includes(searchLower))
     );
   });
 
@@ -194,13 +286,11 @@ export function CallLogsContent() {
               </div>
 
               {isLoading ? (
-                <div className="flex flex-col items-center justify-center py-12">
-                  <div className="relative">
-                    <div className="w-12 h-12 border-4 border-slate-700 rounded-full"></div>
-                    <div className="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin absolute top-0"></div>
-                  </div>
-                  <p className="text-slate-400 mt-4 font-medium">{t('callLogs.loadingCallLogs')}</p>
-                </div>
+                <LoadingScreen
+                  icon={Phone}
+                  message={t('callLogs.loadingCallLogs')}
+                  fullScreen={false}
+                />
               ) : error ? (
                 <div className="flex flex-col items-center justify-center py-12">
                   <div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mb-4">
@@ -228,6 +318,7 @@ export function CallLogsContent() {
                   <p className="text-slate-400">{t('callLogs.noCalls')}</p>
                 </div>
               ) : (
+                <TooltipProvider>
                 <div className="overflow-x-auto">
                   <table className="w-full">
                     <thead>
@@ -236,6 +327,8 @@ export function CallLogsContent() {
                         <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('callLogs.table.endedAt')}</th>
                         <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('callLogs.table.duration')}</th>
                         <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('callLogs.table.caller')}</th>
+                        <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('callLogs.table.outcome')}</th>
+                        <th className="text-center py-3 px-4 text-sm font-medium text-slate-400">{t('callLogs.table.analytics')}</th>
                         {/* Cost column hidden - cost calculation not fully implemented
                         <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('callLogs.table.cost')}</th>
                         */}
@@ -252,7 +345,23 @@ export function CallLogsContent() {
                           <td className="py-3 px-4 text-sm text-slate-300">{formatDateTime(call.started_at)}</td>
                           <td className="py-3 px-4 text-sm text-slate-300">{formatDateTime(call.ended_at)}</td>
                           <td className="py-3 px-4 text-sm text-slate-300">{formatDuration(call.duration)}</td>
-                          <td className="py-3 px-4 text-sm text-white">{maskPhoneNumber(call.caller)}</td>
+                          <td className="py-3 px-4 text-sm text-white">{call.caller || '-'}</td>
+                          <td className="py-3 px-4 text-sm">
+                            {call.outcome ? (
+                              <span className={`px-2 py-0.5 rounded text-xs font-medium text-white ${getOutcomeColor(call.outcome)}`}>
+                                {formatOutcome(call.outcome)}
+                              </span>
+                            ) : (
+                              <span className="text-slate-400">-</span>
+                            )}
+                          </td>
+                          <td className="py-3 px-4 text-center">
+                            <AnalyticsStatusIndicator
+                              status={call.analytics_status}
+                              error={call.analytics_error}
+                              t={t}
+                            />
+                          </td>
                           {/* Cost cell hidden - cost calculation not fully implemented
                           <td className="py-3 px-4 text-sm text-slate-300">
                             {call.cost_total ? `$${call.cost_total.toFixed(4)}` : '-'}
@@ -266,6 +375,7 @@ export function CallLogsContent() {
                     </tbody>
                   </table>
                 </div>
+                </TooltipProvider>
               )}
             </div>
           </Card>

+ 6 - 5
shopcall.ai-main/src/components/CustomContentList.tsx

@@ -27,7 +27,7 @@ import {
 import { supabase } from "@/lib/supabase";
 import { useShop } from "@/components/context/ShopContext";
 import { useTranslation } from 'react-i18next';
-import { Loader2 } from "lucide-react";
+import { LoadingScreen } from "@/components/ui/loading-screen";
 
 interface CustomContentListProps {
   contentType?: "text_entry" | "pdf_upload";
@@ -206,10 +206,11 @@ export function CustomContentList({
   // Show loading state while shop is being determined from URL or while fetching content
   if (isShopLoading || isShopSelecting || isLoading) {
     return (
-      <div className="text-center py-8 text-slate-400 flex items-center justify-center gap-2">
-        <Loader2 className="h-4 w-4 animate-spin" />
-        {t('customContent.list.loading')}
-      </div>
+      <LoadingScreen
+        icon={FileText}
+        message={t('customContent.list.loading')}
+        fullScreen={false}
+      />
     );
   }
 

+ 7 - 5
shopcall.ai-main/src/components/CustomContentViewer.tsx

@@ -7,10 +7,11 @@ import {
 } from "@/components/ui/dialog";
 import { Button } from "@/components/ui/button";
 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { FileText, File, Loader2, ExternalLink } from "lucide-react";
+import { FileText, File, ExternalLink } from "lucide-react";
 import { Alert, AlertDescription } from "@/components/ui/alert";
 import { supabase } from "@/lib/supabase";
 import { useTranslation } from 'react-i18next';
+import { LoadingScreen } from "@/components/ui/loading-screen";
 
 // Helper to fix Supabase storage URLs to use the correct base URL from environment
 const fixSupabaseStorageUrl = (url: string | undefined): string | undefined => {
@@ -132,10 +133,11 @@ export function CustomContentViewer({
 
         <div className="space-y-4">
           {loading && (
-            <div className="flex items-center justify-center py-12">
-              <Loader2 className="h-8 w-8 animate-spin text-cyan-500" />
-              <span className="ml-2 text-slate-400">{t('customContent.viewer.loading')}</span>
-            </div>
+            <LoadingScreen
+              icon={FileText}
+              message={t('customContent.viewer.loading')}
+              fullScreen={false}
+            />
           )}
 
           {error && (

+ 27 - 16
shopcall.ai-main/src/components/ManageStoreDataContent.tsx

@@ -28,11 +28,12 @@ import {
   AlertDialogHeader,
   AlertDialogTitle,
 } from "@/components/ui/alert-dialog";
-import { Loader2, Search, Package, ChevronLeft, ChevronRight, Tag, Globe, ExternalLink, Plus, Trash2, Clock, CheckCircle, AlertCircle, Settings, Eye, Code } from "lucide-react";
+import { Loader2, Search, Package, ChevronLeft, ChevronRight, Tag, Globe, ExternalLink, Plus, Trash2, Clock, CheckCircle, AlertCircle, Settings, Eye, Code, Store } from "lucide-react";
 import { API_URL } from "@/lib/config";
 import { useToast } from "@/hooks/use-toast";
 import { useTranslation } from "react-i18next";
 import ReactMarkdown from 'react-markdown';
+import { LoadingScreen } from "@/components/ui/loading-screen";
 
 interface StoreData {
   id: string;
@@ -87,7 +88,7 @@ interface ScraperCustomUrl {
   enabled: boolean;
   created_at: string;
   last_scraped_at?: string;
-  status?: 'pending' | 'completed' | 'failed';
+  status?: 'pending' | 'completed' | 'failed' | 'queued';
 }
 
 interface ScraperShopStatus {
@@ -543,12 +544,24 @@ export function ManageStoreDataContent({ defaultTab }: ManageStoreDataContentPro
       });
 
       if (response.ok) {
+        const result = await response.json();
+        // Add the new URL to the list immediately with queued status
+        const newUrl: ScraperCustomUrl = {
+          id: result.custom_url?.id || `temp-${Date.now()}`,
+          url: result.custom_url?.url || newCustomUrl,
+          content_type: result.custom_url?.content_type || newCustomUrlType,
+          enabled: result.custom_url?.enabled ?? true,
+          created_at: result.custom_url?.created_at || new Date().toISOString(),
+          status: result.scrape_status === 'queued' ? 'queued' : 'pending',
+        };
+        setCustomUrls(prev => [newUrl, ...prev]);
         toast({
           title: t('common.success'),
           description: t('manageStoreData.website.toast.urlAddSuccess') || 'Custom URL added successfully',
         });
         setNewCustomUrl("");
-        fetchCustomUrls();
+        // Refetch after a delay to get updated status
+        setTimeout(() => fetchCustomUrls(), 5000);
       } else {
         const error = await response.json();
         // Handle 409 conflict - URL already exists in scraped content
@@ -974,20 +987,12 @@ export function ManageStoreDataContent({ defaultTab }: ManageStoreDataContentPro
   };
 
   if (loading) {
-    return (
-      <div className="flex-1 flex items-center justify-center min-h-screen bg-slate-900">
-        <Loader2 className="w-8 h-8 text-cyan-500 animate-spin" />
-      </div>
-    );
+    return <LoadingScreen icon={Store} message={t('common.loading')} />;
   }
 
   // For dedicated pages, wait for selectedShop from context
   if (isDedicatedPage && !selectedStore) {
-    return (
-      <div className="flex-1 flex items-center justify-center min-h-screen bg-slate-900">
-        <Loader2 className="w-8 h-8 text-cyan-500 animate-spin" />
-      </div>
-    );
+    return <LoadingScreen icon={Store} message={t('common.loading')} />;
   }
 
   if (stores.length === 0) {
@@ -1439,8 +1444,14 @@ export function ManageStoreDataContent({ defaultTab }: ManageStoreDataContentPro
                                     <Badge variant={
                                       customUrl.status === 'completed' ? 'default' :
                                       customUrl.status === 'failed' ? 'destructive' : 'secondary'
-                                    } className="text-xs">
-                                      {customUrl.status}
+                                    } className={`text-xs ${(customUrl.status === 'queued' || customUrl.status === 'pending') ? 'bg-cyan-500/20 text-cyan-400 border-cyan-500/50' : ''}`}>
+                                      {(customUrl.status === 'queued' || customUrl.status === 'pending') && (
+                                        <Loader2 className="w-3 h-3 mr-1 animate-spin" />
+                                      )}
+                                      {customUrl.status === 'queued' ? t('manageStoreData.website.customUrls.scraping', 'Scraping...') :
+                                       customUrl.status === 'pending' ? t('manageStoreData.website.customUrls.pending', 'Pending') :
+                                       customUrl.status === 'completed' ? t('manageStoreData.website.customUrls.scraped', 'Scraped') :
+                                       t('manageStoreData.website.customUrls.failed', 'Failed')}
                                     </Badge>
                                   )}
                                   {customUrl.last_scraped_at && (
@@ -1563,7 +1574,7 @@ export function ManageStoreDataContent({ defaultTab }: ManageStoreDataContentPro
                                               setPreviewContent(content);
                                               setPreviewMode('rendered');
                                             }}
-                                            className="border-slate-600 hover:bg-slate-700 text-slate-200"
+                                            className="bg-slate-700 border-slate-600 hover:bg-slate-600 text-slate-200"
                                           >
                                             <Eye className="w-3 h-3 mr-1" />
                                             {t('manageStoreData.website.contentViewer.preview', 'Preview')}

+ 5 - 12
shopcall.ai-main/src/components/StoreProtectedRoute.tsx

@@ -4,6 +4,7 @@ import { AlertCircle, Store } from "lucide-react";
 import { Card, CardContent } from "@/components/ui/card";
 import { Button } from "@/components/ui/button";
 import { useTranslation } from "react-i18next";
+import { LoadingScreen } from "@/components/ui/loading-screen";
 
 /**
  * StoreProtectedRoute - Protects routes that require an active store to be selected
@@ -22,18 +23,10 @@ const StoreProtectedRoute = () => {
   // Wait for stores to load - show loading until stores are actually fetched
   if (isLoading || !storesLoaded) {
     return (
-      <div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center px-4">
-        <div className="w-full max-w-md text-center">
-          <div className="relative mx-auto w-16 h-16 mb-6">
-            <div className="absolute inset-0 rounded-full border-4 border-[#52b3d0]/20"></div>
-            <div className="absolute inset-0 rounded-full border-4 border-transparent border-t-[#52b3d0] animate-spin"></div>
-            <div className="absolute inset-2 rounded-full bg-[#52b3d0]/10 flex items-center justify-center">
-              <Store className="w-6 h-6 text-[#52b3d0]" />
-            </div>
-          </div>
-          <p className="text-slate-400">{t('storeProtectedRoute.loadingStores') || 'Loading stores...'}</p>
-        </div>
-      </div>
+      <LoadingScreen
+        icon={Store}
+        message={t('storeProtectedRoute.loadingStores') || 'Loading stores...'}
+      />
     );
   }
 

+ 53 - 0
shopcall.ai-main/src/components/ui/loading-screen.tsx

@@ -0,0 +1,53 @@
+import { LucideIcon, Loader2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+interface LoadingScreenProps {
+  /** Optional message to display under the spinner */
+  message?: string;
+  /** Optional icon to display in the center of the spinner. Defaults to a generic loading icon */
+  icon?: LucideIcon;
+  /** Whether to show the full-screen gradient background. Defaults to true */
+  fullScreen?: boolean;
+}
+
+/**
+ * A reusable loading screen component with an animated circular spinner.
+ * Used throughout the application for consistent loading states.
+ */
+export function LoadingScreen({
+  message,
+  icon: Icon = Loader2,
+  fullScreen = true
+}: LoadingScreenProps) {
+  const { t } = useTranslation();
+
+  const content = (
+    <div className="w-full max-w-md text-center">
+      <div className="relative mx-auto w-16 h-16 mb-6">
+        {/* Outer static ring */}
+        <div className="absolute inset-0 rounded-full border-4 border-[#52b3d0]/20"></div>
+        {/* Spinning ring */}
+        <div className="absolute inset-0 rounded-full border-4 border-transparent border-t-[#52b3d0] animate-spin"></div>
+        {/* Inner circle with icon */}
+        <div className="absolute inset-2 rounded-full bg-[#52b3d0]/10 flex items-center justify-center">
+          <Icon className="w-6 h-6 text-[#52b3d0]" />
+        </div>
+      </div>
+      <p className="text-slate-400">{message || t('common.loading')}</p>
+    </div>
+  );
+
+  if (fullScreen) {
+    return (
+      <div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center px-4">
+        {content}
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex items-center justify-center py-12 px-4">
+      {content}
+    </div>
+  );
+}

+ 30 - 5
shopcall.ai-main/src/i18n/locales/de.json

@@ -257,7 +257,9 @@
     "justNow": "gerade eben",
     "minsAgo": "vor {{count}} Min.",
     "hoursAgo": "vor {{count}} Std.",
-    "daysAgo": "vor {{count}} Tagen"
+    "daysAgo": "vor {{count}} Tagen",
+    "free": "Kostenlos",
+    "note": "Hinweis"
   },
   "signup": {
     "title": "Registrieren",
@@ -324,8 +326,18 @@
       "actions": "Aktionen",
       "startedAt": "Begonnen",
       "endedAt": "Beendet",
-      "caller": "Anrufer"
-    },
+      "caller": "Anrufer",
+      "analytics": "Analyse"
+    },
+    "analyticsStatus": {
+      "pending": "Analyse ausstehend",
+      "running": "Analyse läuft...",
+      "completed": "Analyse abgeschlossen",
+      "failed": "Analyse fehlgeschlagen"
+    },
+    "retryAnalytics": "Analyse wiederholen",
+    "runAnalytics": "Analyse starten",
+    "triggeringAnalytics": "Analyse wird gestartet...",
     "noCalls": "Keine Anrufe gefunden",
     "exportDialog": {
       "title": "Anrufprotokolle Exportieren",
@@ -368,7 +380,12 @@
     "addedAgo": "Hinzugefügt {{time}}",
     "relatedCustomers": "Zugehörige Kunden",
     "relatedOrders": "Zugehörige Bestellungen",
-    "relatedProducts": "Zugehörige Produkte"
+    "relatedProducts": "Zugehörige Produkte",
+    "noRelatedData": "Keine zugehörigen Daten gefunden",
+    "analyticsRunning": "Analyse läuft...",
+    "analyticsFailed": "Analyse fehlgeschlagen",
+    "analyticsError": "Fehler",
+    "satisfaction": "Kundenzufriedenheit"
   },
   "outcomes": {
     "order_inquiry": "Bestellanfrage",
@@ -854,7 +871,15 @@
       "autoRegisterPasswordHint": "Für zukünftige Anmeldungen nutzen Sie bitte die Passwort-Zurücksetzen-Option.",
       "registrationFailed": "Registrierung fehlgeschlagen",
       "emailMismatch": "Ihre angemeldete E-Mail ({{userEmail}}) unterscheidet sich von der Shop-E-Mail ({{shopEmail}}).",
-      "switchToShopEmail": "Zu {{email}} wechseln"
+      "switchToShopEmail": "Zu {{email}} wechseln",
+      "selectPhoneForQuickStart": "Wählen Sie eine Telefonnummer für Ihren KI-Assistenten",
+      "selectPhoneToQuickStart": "Bitte wählen Sie eine Telefonnummer für den Schnellstart.",
+      "choosePhoneNumber": "Wählen Sie Ihre Telefonnummer",
+      "pickPhoneDescription": "Wählen Sie eine Telefonnummer, unter der Ihre Kunden Sie erreichen können",
+      "tollFree": "Gebührenfrei",
+      "local": "Lokal",
+      "noPhoneNumbersInCountry": "In diesem Land sind keine Telefonnummern verfügbar.",
+      "phoneActivationNote": "Ihre ausgewählte Telefonnummer wird sofort aktiviert und ist bereit, Anrufe zu empfangen. Sie können später über Ihr Dashboard weitere Nummern hinzufügen."
     },
     "shoprenter": {
       "seo": {

+ 30 - 5
shopcall.ai-main/src/i18n/locales/en.json

@@ -257,7 +257,9 @@
     "justNow": "just now",
     "minsAgo": "{{count}} mins ago",
     "hoursAgo": "{{count}} hours ago",
-    "daysAgo": "{{count}} days ago"
+    "daysAgo": "{{count}} days ago",
+    "free": "Free",
+    "note": "Note"
   },
   "signup": {
     "title": "Sign Up",
@@ -327,8 +329,18 @@
       "actions": "Actions",
       "startedAt": "Started At",
       "endedAt": "Ended At",
-      "caller": "Caller"
-    },
+      "caller": "Caller",
+      "analytics": "Analytics"
+    },
+    "analyticsStatus": {
+      "pending": "Analytics pending",
+      "running": "Analytics running...",
+      "completed": "Analytics completed",
+      "failed": "Analytics failed"
+    },
+    "retryAnalytics": "Retry Analytics",
+    "runAnalytics": "Run Analytics",
+    "triggeringAnalytics": "Starting analytics...",
     "exportDialog": {
       "title": "Export Call Logs",
       "description": "Export {{count}} call logs in your preferred format",
@@ -370,7 +382,12 @@
     "addedAgo": "Added {{time}}",
     "relatedCustomers": "Related Customers",
     "relatedOrders": "Related Orders",
-    "relatedProducts": "Related Products"
+    "relatedProducts": "Related Products",
+    "noRelatedData": "No related data found",
+    "analyticsRunning": "Analysis in progress...",
+    "analyticsFailed": "Analysis failed",
+    "analyticsError": "Error",
+    "satisfaction": "Customer Satisfaction"
   },
   "outcomes": {
     "order_inquiry": "Order Inquiry",
@@ -1054,7 +1071,15 @@
       "autoRegisterPasswordHint": "For future logins, please use the password reset option.",
       "registrationFailed": "Registration Failed",
       "emailMismatch": "Your logged-in email ({{userEmail}}) differs from the shop email ({{shopEmail}}).",
-      "switchToShopEmail": "Switch to {{email}} instead"
+      "switchToShopEmail": "Switch to {{email}} instead",
+      "selectPhoneForQuickStart": "Select a phone number for your AI assistant",
+      "selectPhoneToQuickStart": "Please select a phone number to use Quick start.",
+      "choosePhoneNumber": "Choose Your Phone Number",
+      "pickPhoneDescription": "Pick a phone number for your customers to reach you",
+      "tollFree": "Toll-free",
+      "local": "Local",
+      "noPhoneNumbersInCountry": "No phone numbers available in this country.",
+      "phoneActivationNote": "Your selected phone number will be instantly activated and ready to receive calls. You can always add more numbers later from your dashboard."
     },
     "shoprenter": {
       "seo": {

+ 30 - 5
shopcall.ai-main/src/i18n/locales/hu.json

@@ -257,7 +257,9 @@
     "justNow": "épp most",
     "minsAgo": "{{count}} perce",
     "hoursAgo": "{{count}} órája",
-    "daysAgo": "{{count}} napja"
+    "daysAgo": "{{count}} napja",
+    "free": "Ingyenes",
+    "note": "Megjegyzés"
   },
   "signup": {
     "title": "Regisztráció",
@@ -326,8 +328,18 @@
       "actions": "Műveletek",
       "startedAt": "Kezdés",
       "endedAt": "Befejezés",
-      "caller": "Hívó"
-    },
+      "caller": "Hívó",
+      "analytics": "Elemzés"
+    },
+    "analyticsStatus": {
+      "pending": "Elemzés függőben",
+      "running": "Elemzés folyamatban...",
+      "completed": "Elemzés kész",
+      "failed": "Elemzés sikertelen"
+    },
+    "retryAnalytics": "Elemzés újra",
+    "runAnalytics": "Elemzés indítása",
+    "triggeringAnalytics": "Elemzés indítása...",
     "noCalls": "Nem található hívás",
     "exportDialog": {
       "title": "Hívásnapló Exportálása",
@@ -370,7 +382,12 @@
     "addedAgo": "Hozzáadva {{time}}",
     "relatedCustomers": "Kapcsolódó Ügyfelek",
     "relatedOrders": "Kapcsolódó Rendelések",
-    "relatedProducts": "Kapcsolódó Termékek"
+    "relatedProducts": "Kapcsolódó Termékek",
+    "noRelatedData": "Nem található kapcsolódó adat",
+    "analyticsRunning": "Elemzés folyamatban...",
+    "analyticsFailed": "Az elemzés sikertelen",
+    "analyticsError": "Hiba",
+    "satisfaction": "Ügyfél Elégedettség"
   },
   "outcomes": {
     "order_inquiry": "Rendelés Érdeklődés",
@@ -932,7 +949,15 @@
       "autoRegisterPasswordHint": "A jövőbeni bejelentkezéshez használja a jelszó-visszaállítás opciót.",
       "registrationFailed": "Regisztráció sikertelen",
       "emailMismatch": "A bejelentkezett email címe ({{userEmail}}) eltér az áruház email címétől ({{shopEmail}}).",
-      "switchToShopEmail": "Váltás erre: {{email}}"
+      "switchToShopEmail": "Váltás erre: {{email}}",
+      "selectPhoneForQuickStart": "Válasszon telefonszámot az AI asszisztenshez",
+      "selectPhoneToQuickStart": "Kérjük, válasszon telefonszámot a gyors indításhoz.",
+      "choosePhoneNumber": "Válasszon telefonszámot",
+      "pickPhoneDescription": "Válasszon telefonszámot, amin ügyfelei elérhetik",
+      "tollFree": "Díjmentes",
+      "local": "Helyi",
+      "noPhoneNumbersInCountry": "Ebben az országban nincs elérhető telefonszám.",
+      "phoneActivationNote": "A kiválasztott telefonszám azonnal aktiválódik és készen áll hívások fogadására. Később a vezérlőpultból további számokat adhat hozzá."
     },
     "shoprenter": {
       "seo": {

+ 3 - 0
shopcall.ai-main/src/lib/config.ts

@@ -1,2 +1,5 @@
 // Backend API URL (Supabase Edge Functions)
 export const API_URL = import.meta.env.VITE_API_URL || 'https://ztklqodcdjeqpsvhlpud.supabase.co/functions/v1';
+
+// Feature flags
+export const ENABLE_MANUAL_ANALYTICS = import.meta.env.VITE_ENABLE_MANUAL_ANALYTICS === 'true';

+ 210 - 85
shopcall.ai-main/src/pages/IntegrationsRedirect.tsx

@@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
 import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
 import { Button } from "@/components/ui/button";
 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
-import { Store, LogIn, UserPlus, Loader2, CheckCircle, AlertCircle, ArrowRight, Phone, Link, Mail, Sparkles } from "lucide-react";
+import { Store, LogIn, UserPlus, Loader2, CheckCircle, AlertCircle, ArrowRight, Phone, Link, Mail, Sparkles, Globe } from "lucide-react";
 import { useAuth } from "@/components/context/AuthContext";
 import { API_URL } from "@/lib/config";
 import { useToast } from "@/hooks/use-toast";
@@ -25,6 +25,7 @@ interface PhoneNumber {
   country_code: string;
   country_name: string;
   location: string;
+  phone_type: string; // 'local' or 'toll-free'
   price: number;
   currency: string;
   is_available: boolean;
@@ -275,50 +276,50 @@ export default function IntegrationsRedirect() {
     return null;
   };
 
-  // Fetch available countries when user is authenticated
+  // Fetch available countries - works for both authenticated and non-authenticated users
+  // Uses Supabase client with anon key - RLS policy allows reading available phone numbers
   useEffect(() => {
     const fetchCountries = async () => {
-      if (!isAuthenticated || !pendingInstall) return;
+      if (!pendingInstall) return;
+      // Only fetch when dialog is shown (auth or assign)
+      if (!showAuthDialog && !showAssignDialog) return;
 
       setLoadingCountries(true);
       try {
-        const sessionData = localStorage.getItem('session_data');
-        if (!sessionData) return;
-
-        const session = JSON.parse(sessionData);
-        const response = await fetch(`${API_URL}/api/phone-numbers?group_by=countries`, {
-          method: 'GET',
-          headers: {
-            'Authorization': `Bearer ${session.session.access_token}`,
-            'Content-Type': 'application/json'
-          }
-        });
-
-        if (response.ok) {
-          const data = await response.json();
-          setCountries(data.countries || []);
-
-          // Determine which country to auto-select
-          let countryToSelect = data.user_country;
-
-          // If user doesn't have a country set, try browser detection
-          if (!countryToSelect) {
-            countryToSelect = detectBrowserCountry();
-            console.log('[Integrations] Browser detected country:', countryToSelect);
-          }
+        // Use Supabase client directly - RLS policy allows anon to read available phone numbers
+        const { data: phoneData, error } = await supabase
+          .from('phone_numbers')
+          .select('country_code, country_name')
+          .eq('is_active', true)
+          .eq('is_available', true)
+          .is('assigned_to_store_id', null)
+          .order('country_name', { ascending: true });
+
+        if (error) {
+          console.error('Error fetching countries:', error);
+          return;
+        }
 
-          if (countryToSelect) {
-            setUserCountry(countryToSelect);
-            // Check if the country is in the available countries list
-            const hasCountry = (data.countries || []).some(
-              (c: Country) => c.country_code === countryToSelect
-            );
-            if (hasCountry) {
-              setSelectedCountry(countryToSelect);
-            }
+        // Get unique countries
+        const uniqueCountries = Array.from(
+          new Map((phoneData || []).map(c => [c.country_code, c])).values()
+        ) as Country[];
+
+        setCountries(uniqueCountries);
+
+        // Try browser detection for auto-select
+        const countryToSelect = detectBrowserCountry();
+        console.log('[Integrations] Browser detected country:', countryToSelect);
+
+        if (countryToSelect) {
+          setUserCountry(countryToSelect);
+          // Check if the country is in the available countries list
+          const hasCountry = uniqueCountries.some(
+            (c: Country) => c.country_code === countryToSelect
+          );
+          if (hasCountry) {
+            setSelectedCountry(countryToSelect);
           }
-        } else {
-          console.error('Failed to fetch countries:', response.status);
         }
       } catch (err) {
         console.error('Error fetching countries:', err);
@@ -328,44 +329,44 @@ export default function IntegrationsRedirect() {
     };
 
     fetchCountries();
-  }, [isAuthenticated, pendingInstall]);
+  }, [pendingInstall, showAuthDialog, showAssignDialog]);
 
-  // Fetch cities when country is selected
+  // Fetch cities when country is selected (for Assign Dialog which still uses city selector)
+  // Uses Supabase client with anon key - RLS policy allows reading available phone numbers
   useEffect(() => {
     const fetchCities = async () => {
       if (!selectedCountry) {
         setCities([]);
         setSelectedCity('');
-        setPhoneNumbers([]);
-        setSelectedPhoneNumber('');
         return;
       }
 
       setLoadingCities(true);
       setCities([]);
       setSelectedCity('');
-      setPhoneNumbers([]);
-      setSelectedPhoneNumber('');
 
       try {
-        const sessionData = localStorage.getItem('session_data');
-        if (!sessionData) return;
+        // Use Supabase client directly - RLS policy allows anon to read available phone numbers
+        const { data: phoneData, error } = await supabase
+          .from('phone_numbers')
+          .select('location')
+          .eq('is_active', true)
+          .eq('is_available', true)
+          .eq('country_code', selectedCountry)
+          .is('assigned_to_store_id', null)
+          .order('location', { ascending: true });
+
+        if (error) {
+          console.error('Error fetching cities:', error);
+          return;
+        }
 
-        const session = JSON.parse(sessionData);
-        const response = await fetch(`${API_URL}/api/phone-numbers?group_by=cities&country=${selectedCountry}`, {
-          method: 'GET',
-          headers: {
-            'Authorization': `Bearer ${session.session.access_token}`,
-            'Content-Type': 'application/json'
-          }
-        });
+        // Get unique cities
+        const uniqueCities = Array.from(
+          new Set((phoneData || []).map(c => c.location))
+        ).filter(Boolean) as string[];
 
-        if (response.ok) {
-          const data = await response.json();
-          setCities(data.cities || []);
-        } else {
-          console.error('Failed to fetch cities:', response.status);
-        }
+        setCities(uniqueCities);
       } catch (err) {
         console.error('Error fetching cities:', err);
       } finally {
@@ -376,10 +377,11 @@ export default function IntegrationsRedirect() {
     fetchCities();
   }, [selectedCountry]);
 
-  // Fetch phone numbers when city is selected
+  // Fetch phone numbers when country is selected
+  // Uses Supabase client with anon key - RLS policy allows reading available phone numbers
   useEffect(() => {
     const fetchPhoneNumbers = async () => {
-      if (!selectedCountry || !selectedCity) {
+      if (!selectedCountry) {
         setPhoneNumbers([]);
         setSelectedPhoneNumber('');
         return;
@@ -390,27 +392,23 @@ export default function IntegrationsRedirect() {
       setSelectedPhoneNumber('');
 
       try {
-        const sessionData = localStorage.getItem('session_data');
-        if (!sessionData) return;
-
-        const session = JSON.parse(sessionData);
-        const response = await fetch(
-          `${API_URL}/api/phone-numbers?available=true&country=${selectedCountry}&city=${encodeURIComponent(selectedCity)}`,
-          {
-            method: 'GET',
-            headers: {
-              'Authorization': `Bearer ${session.session.access_token}`,
-              'Content-Type': 'application/json'
-            }
-          }
-        );
-
-        if (response.ok) {
-          const data = await response.json();
-          setPhoneNumbers(data.phone_numbers || []);
-        } else {
-          console.error('Failed to fetch phone numbers:', response.status);
+        // Use Supabase client directly - RLS policy allows anon to read available phone numbers
+        const { data: phoneData, error } = await supabase
+          .from('phone_numbers')
+          .select('id, phone_number, country_code, country_name, location, phone_type, price, currency, is_available')
+          .eq('is_active', true)
+          .eq('is_available', true)
+          .eq('country_code', selectedCountry)
+          .is('assigned_to_store_id', null)
+          .order('location', { ascending: true })
+          .order('phone_number', { ascending: true });
+
+        if (error) {
+          console.error('Error fetching phone numbers:', error);
+          return;
         }
+
+        setPhoneNumbers((phoneData || []) as PhoneNumber[]);
       } catch (err) {
         console.error('Error fetching phone numbers:', err);
       } finally {
@@ -419,7 +417,7 @@ export default function IntegrationsRedirect() {
     };
 
     fetchPhoneNumbers();
-  }, [selectedCountry, selectedCity]);
+  }, [selectedCountry]);
 
   // Handle auth state changes
   useEffect(() => {
@@ -710,13 +708,140 @@ export default function IntegrationsRedirect() {
             </DialogDescription>
           </DialogHeader>
 
+          {/* Phone Number Selection for auto-registration */}
+          {shopInfo?.shop_email && !shopInfo.email_exists && (
+            <div className="mt-4 space-y-4">
+              {/* Header */}
+              <div className="flex items-center gap-2 text-white">
+                <Phone className="w-5 h-5 text-cyan-400" />
+                <h3 className="font-semibold">{t('integrations.oauth.choosePhoneNumber', 'Choose Your Phone Number')}</h3>
+              </div>
+              <p className="text-sm text-slate-400">
+                {t('integrations.oauth.pickPhoneDescription', 'Pick a phone number for your customers to reach you')}
+              </p>
+
+              {/* Country Selector */}
+              <div className="space-y-2">
+                <Label className="text-slate-300 text-sm">
+                  {t('integrations.oauth.selectCountry', 'Select Country')}
+                </Label>
+                {loadingCountries ? (
+                  <div className="flex items-center justify-center py-3">
+                    <Loader2 className="w-5 h-5 text-cyan-500 animate-spin" />
+                    <span className="ml-2 text-sm text-slate-400">
+                      {t('integrations.oauth.loadingCountries', 'Loading countries...')}
+                    </span>
+                  </div>
+                ) : countries.length > 0 ? (
+                  <Select value={selectedCountry} onValueChange={setSelectedCountry}>
+                    <SelectTrigger className="bg-slate-700/50 border-slate-600 text-white">
+                      <SelectValue placeholder={t('integrations.oauth.selectCountryPlaceholder', 'Select a country')} />
+                    </SelectTrigger>
+                    <SelectContent className="bg-slate-700 border-slate-600">
+                      {countries.map((country) => (
+                        <SelectItem key={country.country_code} value={country.country_code} className="text-white hover:bg-slate-600">
+                          {country.country_name}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                ) : (
+                  <div className="bg-red-500/10 border border-red-500/30 rounded-md p-3">
+                    <p className="text-sm text-red-400">
+                      {t('integrations.oauth.noCountriesError', 'No countries with available phone numbers.')}
+                    </p>
+                  </div>
+                )}
+              </div>
+
+              {/* Phone Number Cards - shown after country selection */}
+              {selectedCountry && (
+                <div className="space-y-2">
+                  {loadingPhoneNumbers ? (
+                    <div className="flex items-center justify-center py-6">
+                      <Loader2 className="w-5 h-5 text-cyan-500 animate-spin" />
+                      <span className="ml-2 text-sm text-slate-400">
+                        {t('integrations.oauth.loadingPhoneNumbers', 'Loading available phone numbers...')}
+                      </span>
+                    </div>
+                  ) : phoneNumbers.length > 0 ? (
+                    <div className="space-y-2 max-h-[280px] overflow-y-auto pr-1">
+                      {phoneNumbers.map((phone) => (
+                        <div
+                          key={phone.id}
+                          onClick={() => setSelectedPhoneNumber(phone.id)}
+                          className={`flex items-center justify-between p-4 rounded-lg border cursor-pointer transition-all ${
+                            selectedPhoneNumber === phone.id
+                              ? 'bg-slate-700/80 border-cyan-500 ring-1 ring-cyan-500/50'
+                              : 'bg-slate-800/50 border-slate-700 hover:bg-slate-700/50 hover:border-slate-600'
+                          }`}
+                        >
+                          <div className="flex items-center gap-3">
+                            {/* Radio button */}
+                            <div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
+                              selectedPhoneNumber === phone.id
+                                ? 'border-cyan-500 bg-cyan-500'
+                                : 'border-slate-500'
+                            }`}>
+                              {selectedPhoneNumber === phone.id && (
+                                <div className="w-2 h-2 rounded-full bg-white" />
+                              )}
+                            </div>
+
+                            <div>
+                              {/* Phone number */}
+                              <div className="text-white font-mono text-base">
+                                {phone.phone_number}
+                              </div>
+                              {/* Location */}
+                              <div className="flex items-center gap-1 text-slate-400 text-sm mt-0.5">
+                                <Globe className="w-3.5 h-3.5" />
+                                <span>{phone.location}, {phone.country_code}</span>
+                              </div>
+                            </div>
+                          </div>
+
+                          {/* Type badge */}
+                          <span className={`px-3 py-1 rounded-full text-xs font-medium ${
+                            phone.phone_type === 'toll-free'
+                              ? 'bg-slate-600 text-slate-300'
+                              : 'bg-emerald-500/20 text-emerald-400'
+                          }`}>
+                            {phone.phone_type === 'toll-free'
+                              ? t('integrations.oauth.tollFree', 'Toll-free')
+                              : t('integrations.oauth.local', 'Local')
+                            }
+                          </span>
+                        </div>
+                      ))}
+                    </div>
+                  ) : (
+                    <div className="bg-yellow-500/10 border border-yellow-500/30 rounded-md p-3">
+                      <p className="text-sm text-yellow-400">
+                        {t('integrations.oauth.noPhoneNumbersInCountry', 'No phone numbers available in this country.')}
+                      </p>
+                    </div>
+                  )}
+                </div>
+              )}
+
+              {/* Note */}
+              {selectedCountry && phoneNumbers.length > 0 && (
+                <p className="text-xs text-slate-500">
+                  <span className="text-slate-400 font-medium">{t('common.note', 'Note')}:</span>{' '}
+                  {t('integrations.oauth.phoneActivationNote', 'Your selected phone number will be instantly activated and ready to receive calls. You can always add more numbers later from your dashboard.')}
+                </p>
+              )}
+            </div>
+          )}
+
           <div className="space-y-3 mt-4">
             {/* Auto-register option when shop email is available and not already registered */}
             {shopInfo?.shop_email && !shopInfo.email_exists && (
               <Button
-                className="w-full bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-600 hover:to-blue-600 text-white"
+                className="w-full bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-600 hover:to-blue-600 text-white disabled:opacity-50"
                 onClick={handleAutoRegister}
-                disabled={autoRegistering}
+                disabled={autoRegistering || !selectedPhoneNumber || loadingCountries || loadingPhoneNumbers}
               >
                 {autoRegistering ? (
                   <>

+ 35 - 0
shopcall.ai-main/src/types/database.types.ts

@@ -430,3 +430,38 @@ export const CALL_TYPE = {
   OUTBOUND: "outbound" as const,
   INBOUND_DUMMY: "inbound_dummy" as const,
 } as const;
+
+// Analytics status enum
+export const ANALYTICS_STATUS = {
+  PENDING: "pending" as const,
+  RUNNING: "running" as const,
+  COMPLETED: "completed" as const,
+  FAILED: "failed" as const,
+} as const;
+
+export type AnalyticsStatus = typeof ANALYTICS_STATUS[keyof typeof ANALYTICS_STATUS];
+
+// Related entity types for call log analytics
+export interface RelatedCustomer {
+  id: string;
+  email?: string;
+  name?: string;
+  phone?: string;
+  match_reason: string;
+}
+
+export interface RelatedOrder {
+  id: string;
+  order_number: string;
+  status?: string;
+  total?: string;
+  match_reason: string;
+}
+
+export interface RelatedProduct {
+  id: string;
+  name: string;
+  sku?: string;
+  price?: string;
+  match_reason: string;
+}

+ 20 - 8
supabase/.env.example

@@ -6,9 +6,9 @@
 # Supabase Configuration
 # -----------------------------------------------------------------------------
 # Get these from: Supabase Dashboard → Settings → API
-SUPABASE_URL=https://YOUR_PROJECT.supabase.co
-SUPABASE_ANON_KEY=your_supabase_anon_key_here
-SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here
+SUPABASE_URL=https://api.shopcall.ai
+SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp0a2xxb2RjZGplcXBzdmhscHVkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDk3MDkzODMsImV4cCI6MjA2NTI4NTM4M30.Z4D2Ly8_VZc7SoAwDPncBo2XZQbNcps9ATu7u-tWgqY
+SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp0a2xxb2RjZGplcXBzdmhscHVkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0OTcwOTM4MywiZXhwIjoyMDY1Mjg1MzgzfQ.pcZlXh-oPk-2vYdFoMJeQ4af4thDTYelYxMYzWtyfy8
 
 # -----------------------------------------------------------------------------
 # Frontend Configuration
@@ -16,7 +16,7 @@ SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here
 # URL where your frontend is hosted (used for OAuth redirects)
 # For production: https://yourdomain.com
 # For local dev: http://localhost:8080
-FRONTEND_URL=http://localhost:8080
+FRONTEND_URL=https://shopcall.ai
 
 # -----------------------------------------------------------------------------
 # E-commerce Platform OAuth Credentials
@@ -29,8 +29,8 @@ SHOPIFY_API_SECRET=your_shopify_api_secret
 
 # ShopRenter App Credentials
 # Get these from: ShopRenter App Store → Your App
-SHOPRENTER_CLIENT_ID=your_shoprenter_client_id
-SHOPRENTER_CLIENT_SECRET=your_shoprenter_client_secret
+SHOPRENTER_CLIENT_ID=2d62a20d40620f95573dec6a
+SHOPRENTER_CLIENT_SECRET=9284a57d5b9a439037c4a77b
 
 # WooCommerce OAuth (configured per-store, no global credentials needed)
 
@@ -53,8 +53,20 @@ RESEND_API_KEY=your_resend_api_key_here
 # Default scraper API configuration (can be overridden per store)
 # For local development: http://127.0.0.1:3000
 # For production: https://your-scraper-api.com
-DEFAULT_SCRAPER_API_URL=http://127.0.0.1:3000
-DEFAULT_SCRAPER_API_SECRET=your_shared_secret_key_here
+DEFAULT_SCRAPER_API_URL=https://crawler-1.shop.static.shopcall.ai
+DEFAULT_SCRAPER_API_SECRET=Uq73P0WPf659tkZGX0P2
+
+# -----------------------------------------------------------------------------
+# AI Analytics Configuration
+# -----------------------------------------------------------------------------
+# OpenRouter API Key for LLM-powered call log analytics
+# Get this from: https://openrouter.ai/keys
+OPENROUTER_API_KEY=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
+
+# Internal API key for analytics endpoint authentication
+# Generate with: openssl rand -hex 32
+# This key authenticates requests to the call-log-analytics edge function
+ANALYTICS_INTERNAL_API_KEY=int_shopcall_cOftLHMgH-o6JG5z6qfPI9xqswUq2ClBysMiCqKAoK3KkU7O
 
 # -----------------------------------------------------------------------------
 # Database Configuration (for pg_cron scheduled sync)

+ 286 - 0
supabase/functions/_shared/openrouter-chat.ts

@@ -0,0 +1,286 @@
+/**
+ * OpenRouter Chat Completion Client
+ *
+ * Provides chat completion functionality using OpenRouter API
+ * with support for tool/function calling.
+ *
+ * Used primarily for call log analytics with Google Gemini 2.5 Flash.
+ */
+
+// OpenRouter API configuration
+const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
+const DEFAULT_MODEL = 'google/gemini-2.5-flash';
+
+// Type definitions
+export interface ChatMessage {
+  role: 'system' | 'user' | 'assistant' | 'tool';
+  content: string | null;
+  tool_calls?: ToolCall[];
+  tool_call_id?: string;
+  name?: string;
+}
+
+export interface ToolCall {
+  id: string;
+  type: 'function';
+  function: {
+    name: string;
+    arguments: string;
+  };
+}
+
+export interface Tool {
+  type: 'function';
+  function: {
+    name: string;
+    description: string;
+    parameters: {
+      type: 'object';
+      properties: Record<string, {
+        type: string;
+        description?: string;
+        enum?: string[];
+      }>;
+      required?: string[];
+    };
+  };
+}
+
+export interface ChatCompletionOptions {
+  model?: string;
+  messages: ChatMessage[];
+  tools?: Tool[];
+  tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
+  response_format?: { type: 'json_object' | 'text' };
+  temperature?: number;
+  max_tokens?: number;
+}
+
+export interface ChatCompletionResponse {
+  id: string;
+  choices: Array<{
+    index: number;
+    message: {
+      role: 'assistant';
+      content: string | null;
+      tool_calls?: ToolCall[];
+    };
+    finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter';
+  }>;
+  usage?: {
+    prompt_tokens: number;
+    completion_tokens: number;
+    total_tokens: number;
+  };
+}
+
+export interface ChatCompletionError {
+  error: {
+    message: string;
+    type: string;
+    code?: string;
+  };
+}
+
+/**
+ * Make a chat completion request to OpenRouter API
+ */
+export async function chatCompletion(
+  options: ChatCompletionOptions
+): Promise<ChatCompletionResponse> {
+  const OPENROUTER_API_KEY = Deno.env.get('OPENROUTER_API_KEY');
+
+  if (!OPENROUTER_API_KEY) {
+    throw new Error('OPENROUTER_API_KEY environment variable not set');
+  }
+
+  const {
+    model = DEFAULT_MODEL,
+    messages,
+    tools,
+    tool_choice,
+    response_format,
+    temperature = 0.3,
+    max_tokens = 4096,
+  } = options;
+
+  const requestBody: Record<string, unknown> = {
+    model,
+    messages,
+    temperature,
+    max_tokens,
+  };
+
+  // Add optional parameters
+  if (tools && tools.length > 0) {
+    requestBody.tools = tools;
+    requestBody.tool_choice = tool_choice || 'auto';
+  }
+
+  if (response_format) {
+    requestBody.response_format = response_format;
+  }
+
+  console.log(`[OpenRouter] Making chat completion request with model: ${model}`);
+  console.log(`[OpenRouter] Messages count: ${messages.length}, Tools count: ${tools?.length || 0}`);
+
+  const response = await fetch(OPENROUTER_API_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
+      'HTTP-Referer': 'https://shopcall.ai',
+      'X-Title': 'ShopCall.ai Call Analytics',
+    },
+    body: JSON.stringify(requestBody),
+  });
+
+  if (!response.ok) {
+    const errorText = await response.text();
+    console.error(`[OpenRouter] API error (${response.status}):`, errorText);
+
+    let errorData: ChatCompletionError;
+    try {
+      errorData = JSON.parse(errorText);
+    } catch {
+      throw new Error(`OpenRouter API error: ${response.status} - ${errorText}`);
+    }
+
+    throw new Error(`OpenRouter API error: ${errorData.error?.message || response.statusText}`);
+  }
+
+  const data = await response.json() as ChatCompletionResponse;
+
+  console.log(`[OpenRouter] Response received. Finish reason: ${data.choices?.[0]?.finish_reason}`);
+  if (data.usage) {
+    console.log(`[OpenRouter] Token usage - Prompt: ${data.usage.prompt_tokens}, Completion: ${data.usage.completion_tokens}, Total: ${data.usage.total_tokens}`);
+  }
+
+  return data;
+}
+
+/**
+ * Helper to extract text content from a chat completion response
+ */
+export function getMessageContent(response: ChatCompletionResponse): string | null {
+  return response.choices?.[0]?.message?.content ?? null;
+}
+
+/**
+ * Helper to check if the response has tool calls
+ */
+export function hasToolCalls(response: ChatCompletionResponse): boolean {
+  return (response.choices?.[0]?.message?.tool_calls?.length ?? 0) > 0;
+}
+
+/**
+ * Helper to get tool calls from a response
+ */
+export function getToolCalls(response: ChatCompletionResponse): ToolCall[] {
+  return response.choices?.[0]?.message?.tool_calls ?? [];
+}
+
+/**
+ * Helper to parse JSON content from response, handling potential markdown code blocks
+ */
+export function parseJsonContent<T>(content: string | null): T | null {
+  if (!content) return null;
+
+  // Try to extract JSON from markdown code blocks if present
+  let jsonString = content.trim();
+
+  // Handle ```json ... ``` blocks
+  const jsonBlockMatch = jsonString.match(/```(?:json)?\s*([\s\S]*?)```/);
+  if (jsonBlockMatch) {
+    jsonString = jsonBlockMatch[1].trim();
+  }
+
+  try {
+    return JSON.parse(jsonString) as T;
+  } catch (error) {
+    console.error('[OpenRouter] Failed to parse JSON content:', error);
+    console.error('[OpenRouter] Raw content:', content.substring(0, 500));
+    return null;
+  }
+}
+
+/**
+ * Create a tool result message for continuing the conversation
+ */
+export function createToolResultMessage(toolCallId: string, result: unknown): ChatMessage {
+  return {
+    role: 'tool',
+    tool_call_id: toolCallId,
+    content: typeof result === 'string' ? result : JSON.stringify(result),
+  };
+}
+
+/**
+ * Run a conversation with tool calls until completion
+ *
+ * @param options Initial chat options
+ * @param toolExecutor Function that executes tool calls and returns results
+ * @param maxIterations Maximum number of tool call iterations (prevents infinite loops)
+ */
+export async function runConversationWithTools(
+  options: ChatCompletionOptions,
+  toolExecutor: (toolCall: ToolCall) => Promise<unknown>,
+  maxIterations: number = 10
+): Promise<ChatCompletionResponse> {
+  const messages = [...options.messages];
+  let iteration = 0;
+  let lastResponse: ChatCompletionResponse;
+
+  while (iteration < maxIterations) {
+    console.log(`[OpenRouter] Conversation iteration ${iteration + 1}`);
+
+    lastResponse = await chatCompletion({
+      ...options,
+      messages,
+    });
+
+    // Check if we have tool calls to process
+    if (!hasToolCalls(lastResponse)) {
+      console.log('[OpenRouter] No more tool calls, conversation complete');
+      return lastResponse;
+    }
+
+    const toolCalls = getToolCalls(lastResponse);
+    console.log(`[OpenRouter] Processing ${toolCalls.length} tool call(s)`);
+
+    // Add assistant message with tool calls
+    messages.push({
+      role: 'assistant',
+      content: null,
+      tool_calls: toolCalls,
+    });
+
+    // Execute each tool call and add results
+    for (const toolCall of toolCalls) {
+      console.log(`[OpenRouter] Executing tool: ${toolCall.function.name}`);
+
+      try {
+        const result = await toolExecutor(toolCall);
+        messages.push(createToolResultMessage(toolCall.id, result));
+      } catch (error) {
+        console.error(`[OpenRouter] Tool execution error for ${toolCall.function.name}:`, error);
+        messages.push(createToolResultMessage(toolCall.id, {
+          error: true,
+          message: error instanceof Error ? error.message : 'Unknown error',
+        }));
+      }
+    }
+
+    iteration++;
+  }
+
+  console.warn(`[OpenRouter] Max iterations (${maxIterations}) reached, forcing completion`);
+
+  // If we hit max iterations, make one final call without tools to get a response
+  return await chatCompletion({
+    ...options,
+    messages,
+    tools: undefined,
+    tool_choice: undefined,
+  });
+}

+ 76 - 2
supabase/functions/api/index.ts

@@ -20,7 +20,8 @@ serve(async (req) => {
 
   try {
     const url = new URL(req.url)
-    const path = url.pathname.replace('/api/', '')
+    // Extract path after /api/ - handles both /api/... and /functions/v1/api/...
+    const path = url.pathname.replace(/^.*\/api\//, '')
 
     // Get user from authorization header
     const authHeader = req.headers.get('authorization')
@@ -2153,12 +2154,85 @@ serve(async (req) => {
       }
     }
 
+    // POST /api/call-logs/:id/trigger-analytics - Manually trigger analytics for a call log
+    const triggerAnalyticsMatch = path.match(/^call-logs\/([a-f0-9-]+)\/trigger-analytics$/)
+    if (triggerAnalyticsMatch && req.method === 'POST') {
+      const callLogId = triggerAnalyticsMatch[1]
+
+      // Verify the call log exists and belongs to user's store
+      const { data: callLog, error: logError } = await supabase
+        .from('call_logs')
+        .select('id, store_id, transcript, analytics_status')
+        .eq('id', callLogId)
+        .single()
+
+      if (logError || !callLog) {
+        return new Response(
+          JSON.stringify({ error: 'Call log not found or access denied' }),
+          { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Check if call log has a transcript
+      if (!callLog.transcript) {
+        return new Response(
+          JSON.stringify({ error: 'Call log has no transcript to analyze' }),
+          { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Check if analytics is already running
+      if (callLog.analytics_status === 'running') {
+        return new Response(
+          JSON.stringify({ error: 'Analytics is already running for this call log' }),
+          { status: 409, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Get internal API key for analytics
+      const analyticsApiKey = Deno.env.get('ANALYTICS_INTERNAL_API_KEY')
+      if (!analyticsApiKey) {
+        return new Response(
+          JSON.stringify({ error: 'Analytics service not configured' }),
+          { status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Trigger analytics (non-blocking)
+      console.log(`[API] Triggering analytics for call log: ${callLogId}`)
+      fetch(`${supabaseUrl}/functions/v1/call-log-analytics`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': `Bearer ${analyticsApiKey}`
+        },
+        body: JSON.stringify({ call_log_id: callLogId })
+      }).then(res => {
+        if (res.ok) {
+          console.log(`[API] Analytics trigger successful for call log: ${callLogId}`)
+        } else {
+          console.error(`[API] Analytics trigger failed: ${res.status}`)
+        }
+      }).catch(err => {
+        console.error('[API] Analytics trigger error:', err)
+      })
+
+      return new Response(
+        JSON.stringify({
+          success: true,
+          message: 'Analytics processing started',
+          call_log_id: callLogId
+        }),
+        { status: 202, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
     // GET /api/call-logs - List all call logs for the user's stores
     if (path === 'call-logs' && req.method === 'GET') {
       // Fetch call logs - RLS policy ensures user can only see logs for their stores
       const { data: callLogs, error: logsError } = await supabase
         .from('call_logs')
-        .select('id, store_id, created_at, started_at, ended_at, duration, caller, cost_total')
+        .select('id, store_id, created_at, started_at, ended_at, duration, caller, cost_total, outcome, analytics_status, analytics_error')
         .order('created_at', { ascending: false })
 
       if (logsError) {

+ 748 - 0
supabase/functions/call-log-analytics/index.ts

@@ -0,0 +1,748 @@
+/**
+ * Call Log Analytics Edge Function
+ *
+ * Analyzes call transcripts using Google Gemini 2.5 Flash via OpenRouter.
+ * The LLM uses MCP tools to search for related products, orders, and customers.
+ *
+ * Triggered:
+ * - Automatically after VAPI webhook inserts a call log (non-blocking)
+ * - Manually via API for retry functionality
+ *
+ * Authentication: Internal API key (Bearer token)
+ */
+
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
+import { createClient, SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2';
+import {
+  requireInternalApiKey,
+  createInternalApiKeyErrorResponse
+} from '../_shared/internal-api-key-auth.ts';
+import {
+  chatCompletion,
+  runConversationWithTools,
+  parseJsonContent,
+  getMessageContent,
+  Tool,
+  ToolCall,
+  ChatMessage
+} from '../_shared/openrouter-chat.ts';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+  'Access-Control-Allow-Methods': 'POST, OPTIONS',
+};
+
+// Model to use for analytics
+const ANALYTICS_MODEL = 'google/gemini-2.5-flash';
+
+// Platform to MCP endpoint mapping
+const PLATFORM_MCP_ENDPOINTS: Record<string, string> = {
+  shopify: 'mcp-shopify',
+  woocommerce: 'mcp-woocommerce',
+  shoprenter: 'mcp-shoprenter',
+};
+
+// Tool name prefixes by platform
+const PLATFORM_TOOL_PREFIXES: Record<string, string> = {
+  shopify: 'shopify',
+  woocommerce: 'woocommerce',
+  shoprenter: 'shoprenter',
+};
+
+/**
+ * Analytics response structure from LLM
+ */
+interface AnalyticsResponse {
+  call_outcome: string;
+  summary: string;
+  satisfaction_score: number;
+  related_customers: Array<{
+    id: string;
+    email?: string;
+    name?: string;
+    phone?: string;
+    match_reason: string;
+  }>;
+  related_orders: Array<{
+    id: string;
+    order_number: string;
+    status?: string;
+    total?: string;
+    match_reason: string;
+  }>;
+  related_products: Array<{
+    id: string;
+    name: string;
+    sku?: string;
+    price?: string;
+    match_reason: string;
+  }>;
+}
+
+// Default system prompt (used as fallback if not configured in database)
+const DEFAULT_SYSTEM_PROMPT = `You are a call log analyst for an e-commerce customer service system. Your task is to analyze call transcripts and extract structured insights.
+
+## Your Capabilities
+You have access to tools to search the store's database for:
+- Products (search by name, SKU, or description)
+- Orders (search by order number or customer email)
+- Customers (search by email or phone)
+
+## Important Context
+- Store ID (shop_id): {{SHOP_ID}}
+- Platform: {{PLATFORM_NAME}}
+- Always use shop_id: "{{SHOP_ID}}" when calling any tool
+
+## Your Tasks
+1. Read the transcript carefully
+2. Determine the call outcome based on the conversation
+3. Write a brief summary (1-3 sentences) of what the call was about
+4. Use tools to identify any products, orders, or customers mentioned in the conversation
+5. Return your analysis in JSON format
+
+## Order-Related Conversations - IMPORTANT
+When the conversation involves an order (order inquiry, order status, delivery question, return request, etc.):
+1. First, search for the order using the order number or customer email mentioned
+2. From the order results, extract the customer information (customer_id, email, name)
+3. Then search for the customer record using that information to get full customer details
+4. Include BOTH the order AND the customer in your related entities
+
+This ensures we link the call to both the specific order AND the customer record for better tracking.
+
+Example flow for order-related calls:
+- Customer mentions order #1234
+- You call search_orders with order_number="#1234"
+- Order result contains customer_email="john@example.com"
+- You call search_customers with email="john@example.com"
+- Include both the order and customer in your response
+
+## Call Outcome Values
+You MUST choose one of these exact values for call_outcome:
+{{CALL_OUTCOME_VALUES}}
+
+Choose the most appropriate outcome based on the conversation:
+- "order_inquiry": Customer asking about order status, delivery, tracking
+- "product_inquiry": Customer asking about products, availability, pricing
+- "order_placed": Customer placed a new order during the call
+- "complaint": Customer expressing dissatisfaction or filing a complaint
+- "return_request": Customer requesting to return or exchange products
+- "general_question": General questions not related to orders/products
+- "callback_requested": Customer requested a callback
+- "no_answer": Call was not answered
+- "voicemail": Reached voicemail
+- "wrong_number": Wrong number or invalid call
+- "resolved": Customer's issue was successfully resolved
+- "escalated": Issue was escalated to a supervisor/specialist
+- "abandoned": Call was abandoned/disconnected
+
+## Tool Usage Guidelines
+- Only search for entities actually mentioned in the transcript
+- If a customer mentions an order number (like #1234 or Order 1234), search for it
+- If a customer provides their email or phone, search for their customer record
+- If specific products are discussed by name or SKU, search for them
+- Don't make up search queries - only search for things explicitly mentioned
+- For order-related calls: ALWAYS try to find the associated customer from the order data
+
+## Customer Satisfaction Score
+You MUST evaluate the customer's satisfaction level based on the transcript and provide a score from 0 to 100:
+- 0-20: Very dissatisfied (angry, frustrated, threatening to leave)
+- 21-40: Dissatisfied (unhappy, disappointed, complaining)
+- 41-60: Neutral (matter-of-fact, no strong emotions)
+- 61-80: Satisfied (pleased, thankful, positive tone)
+- 81-100: Very satisfied (enthusiastic, praising, highly grateful)
+
+Consider these factors:
+- Customer's tone and language throughout the call
+- Whether their issue was resolved
+- How they responded to the assistance provided
+- Any explicit expressions of satisfaction or dissatisfaction
+- The overall flow and outcome of the conversation
+
+## JSON Response Format
+You MUST respond with valid JSON in this exact format:
+{
+  "call_outcome": "<one of the allowed values>",
+  "summary": "<brief 1-3 sentence summary>",
+  "satisfaction_score": <number from 0-100>,
+  "related_customers": [
+    {
+      "id": "<customer id>",
+      "email": "<email if found>",
+      "name": "<name if found>",
+      "phone": "<phone if found>",
+      "match_reason": "<why this customer was identified>"
+    }
+  ],
+  "related_orders": [
+    {
+      "id": "<order id>",
+      "order_number": "<order number>",
+      "status": "<order status if found>",
+      "total": "<order total if found>",
+      "match_reason": "<why this order was identified>"
+    }
+  ],
+  "related_products": [
+    {
+      "id": "<product id>",
+      "name": "<product name>",
+      "sku": "<SKU if found>",
+      "price": "<price if found>",
+      "match_reason": "<why this product was identified>"
+    }
+  ]
+}
+
+If no related customers/orders/products were found or mentioned, use empty arrays [].
+IMPORTANT: Return ONLY the JSON object, no markdown code blocks or additional text.`;
+
+/**
+ * Fetch system prompt from database config
+ */
+async function fetchSystemPromptTemplate(supabase: SupabaseClient): Promise<string> {
+  try {
+    const { data, error } = await supabase
+      .from('system_config')
+      .select('value')
+      .eq('key', 'call_analytics_system_prompt')
+      .single();
+
+    if (error || !data?.value) {
+      console.log('[Analytics] Using default system prompt (not found in system_config)');
+      return DEFAULT_SYSTEM_PROMPT;
+    }
+
+    console.log('[Analytics] Using system prompt from system_config');
+    return data.value;
+  } catch (err) {
+    console.error('[Analytics] Error fetching system prompt:', err);
+    return DEFAULT_SYSTEM_PROMPT;
+  }
+}
+
+/**
+ * Build the system prompt for call analysis by replacing placeholders
+ *
+ * Available placeholders:
+ * - {{SHOP_ID}} - The store's UUID
+ * - {{PLATFORM_NAME}} - The e-commerce platform (shopify, woocommerce, shoprenter)
+ * - {{CALL_OUTCOME_VALUES}} - List of valid call outcome enum values
+ */
+function buildSystemPrompt(
+  promptTemplate: string,
+  callOutcomeValues: string[],
+  shopId: string,
+  platformName: string
+): string {
+  const callOutcomeList = callOutcomeValues.map(v => `- "${v}"`).join('\n');
+
+  return promptTemplate
+    .replace(/\{\{SHOP_ID\}\}/g, shopId)
+    .replace(/\{\{PLATFORM_NAME\}\}/g, platformName)
+    .replace(/\{\{CALL_OUTCOME_VALUES\}\}/g, callOutcomeList);
+}
+
+/**
+ * Build tool definitions for the LLM
+ */
+function buildTools(platformName: string, shopId: string): Tool[] {
+  const prefix = PLATFORM_TOOL_PREFIXES[platformName] || 'shopify';
+
+  return [
+    {
+      type: 'function',
+      function: {
+        name: 'search_products',
+        description: `Search for products in the store by name, SKU, or description. Returns product details including id, name, SKU, price. Always use shop_id: "${shopId}"`,
+        parameters: {
+          type: 'object',
+          properties: {
+            name: {
+              type: 'string',
+              description: 'Product name to search for (partial match)'
+            },
+            sku: {
+              type: 'string',
+              description: 'Product SKU to search for (exact match)'
+            }
+          }
+        }
+      }
+    },
+    {
+      type: 'function',
+      function: {
+        name: 'search_orders',
+        description: `Search for orders by order number or customer email. Returns order details including id, order number, status, total. Always use shop_id: "${shopId}"`,
+        parameters: {
+          type: 'object',
+          properties: {
+            order_number: {
+              type: 'string',
+              description: 'Order number to look up (e.g., "#1001" or "1001")'
+            },
+            customer_email: {
+              type: 'string',
+              description: 'Customer email to search orders for'
+            }
+          }
+        }
+      }
+    },
+    {
+      type: 'function',
+      function: {
+        name: 'search_customers',
+        description: `Search for customers by email or phone number. Returns customer details including id, name, email, phone. Always use shop_id: "${shopId}"`,
+        parameters: {
+          type: 'object',
+          properties: {
+            email: {
+              type: 'string',
+              description: 'Customer email address'
+            },
+            phone: {
+              type: 'string',
+              description: 'Customer phone number'
+            }
+          }
+        }
+      }
+    }
+  ];
+}
+
+/**
+ * Tool call log entry for debugging and auditing
+ */
+interface ToolCallLogEntry {
+  timestamp: string;
+  tool_name: string;
+  mcp_tool_name: string;
+  parameters: Record<string, unknown>;
+  result: unknown;
+  duration_ms: number;
+  success: boolean;
+  error?: string;
+}
+
+// Global array to collect tool call logs during analysis
+let toolCallLogs: ToolCallLogEntry[] = [];
+
+/**
+ * Execute a tool call by calling the appropriate MCP endpoint
+ */
+async function executeMcpTool(
+  toolCall: ToolCall,
+  shopId: string,
+  platformName: string,
+  internalApiKey: string,
+  supabaseUrl: string
+): Promise<unknown> {
+  const startTime = Date.now();
+  const mcpEndpoint = PLATFORM_MCP_ENDPOINTS[platformName];
+  if (!mcpEndpoint) {
+    return { error: `Unsupported platform: ${platformName}` };
+  }
+
+  const toolPrefix = PLATFORM_TOOL_PREFIXES[platformName] || 'shopify';
+  const args = JSON.parse(toolCall.function.arguments || '{}');
+
+  // Map our simplified tool names to MCP tool names
+  let mcpToolName: string;
+  const mcpArgs: Record<string, unknown> = { shop_id: shopId, ...args };
+
+  switch (toolCall.function.name) {
+    case 'search_products':
+      mcpToolName = `${toolPrefix}_get_products`;
+      mcpArgs.limit = 5;
+      break;
+    case 'search_orders':
+      if (args.order_number) {
+        mcpToolName = `${toolPrefix}_get_order`;
+        // Clean order number (remove # prefix if present)
+        // Use order_id for ShopRenter, order_name for Shopify
+        const cleanOrderNumber = args.order_number.replace(/^#/, '');
+        if (platformName === 'shoprenter') {
+          mcpArgs.order_id = cleanOrderNumber;
+        } else {
+          mcpArgs.order_name = cleanOrderNumber;
+        }
+        delete mcpArgs.order_number;
+      } else {
+        mcpToolName = `${toolPrefix}_list_orders`;
+        mcpArgs.limit = 5;
+      }
+      break;
+    case 'search_customers':
+      mcpToolName = `${toolPrefix}_get_customer`;
+      break;
+    default:
+      return { error: `Unknown tool: ${toolCall.function.name}` };
+  }
+
+  console.log(`[Analytics] Calling MCP tool: ${mcpToolName} with args:`, mcpArgs);
+
+  // Build JSON-RPC request for MCP
+  const jsonRpcRequest = {
+    jsonrpc: '2.0',
+    id: crypto.randomUUID(),
+    method: 'tools/call',
+    params: {
+      name: mcpToolName,
+      arguments: mcpArgs
+    }
+  };
+
+  // Prepare log entry
+  const logEntry: ToolCallLogEntry = {
+    timestamp: new Date().toISOString(),
+    tool_name: toolCall.function.name,
+    mcp_tool_name: mcpToolName,
+    parameters: mcpArgs,
+    result: null,
+    duration_ms: 0,
+    success: false
+  };
+
+  try {
+    const response = await fetch(`${supabaseUrl}/functions/v1/${mcpEndpoint}`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${internalApiKey}`,
+        'Accept': 'text/event-stream'
+      },
+      body: JSON.stringify(jsonRpcRequest)
+    });
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      console.error(`[Analytics] MCP call failed (${response.status}):`, errorText);
+      logEntry.duration_ms = Date.now() - startTime;
+      logEntry.error = `MCP call failed: ${response.status}`;
+      logEntry.result = { error: errorText };
+      toolCallLogs.push(logEntry);
+      return { error: `MCP call failed: ${response.status}` };
+    }
+
+    // Parse SSE response
+    const text = await response.text();
+
+    // SSE format: "data: {...}\n\n"
+    const lines = text.split('\n');
+    for (const line of lines) {
+      if (line.startsWith('data: ')) {
+        const data = JSON.parse(line.substring(6));
+        if (data.result) {
+          // Extract text content from MCP response
+          const content = data.result.content;
+          let resultValue: unknown = data.result;
+
+          if (Array.isArray(content) && content.length > 0) {
+            const textContent = content.find((c: any) => c.type === 'text');
+            if (textContent?.text) {
+              resultValue = textContent.text;
+            }
+          }
+
+          // Log successful call
+          logEntry.duration_ms = Date.now() - startTime;
+          logEntry.success = true;
+          logEntry.result = resultValue;
+          toolCallLogs.push(logEntry);
+          console.log(`[Analytics] MCP tool ${mcpToolName} completed in ${logEntry.duration_ms}ms`);
+
+          return resultValue;
+        }
+        if (data.error) {
+          logEntry.duration_ms = Date.now() - startTime;
+          logEntry.error = data.error.message || 'MCP tool error';
+          logEntry.result = data.error;
+          toolCallLogs.push(logEntry);
+          return { error: data.error.message || 'MCP tool error' };
+        }
+      }
+    }
+
+    logEntry.duration_ms = Date.now() - startTime;
+    logEntry.error = 'No valid response from MCP';
+    toolCallLogs.push(logEntry);
+    return { error: 'No valid response from MCP' };
+  } catch (error) {
+    console.error(`[Analytics] MCP call error:`, error);
+    logEntry.duration_ms = Date.now() - startTime;
+    logEntry.error = error instanceof Error ? error.message : 'MCP call failed';
+    toolCallLogs.push(logEntry);
+    return { error: error instanceof Error ? error.message : 'MCP call failed' };
+  }
+}
+
+/**
+ * Main analytics function
+ */
+async function analyzeCallLog(
+  supabase: SupabaseClient,
+  callLogId: string,
+  internalApiKey: string
+): Promise<void> {
+  const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+
+  // Reset tool call logs for this analysis run
+  toolCallLogs = [];
+
+  console.log(`[Analytics] Starting analysis for call log: ${callLogId}`);
+
+  // Update status to running
+  await supabase
+    .from('call_logs')
+    .update({
+      analytics_status: 'running',
+      analytics_started_at: new Date().toISOString(),
+      analytics_error: null
+    })
+    .eq('id', callLogId);
+
+  try {
+    // Fetch call log with store info
+    const { data: callLog, error: callLogError } = await supabase
+      .from('call_logs')
+      .select('id, transcript, store_id')
+      .eq('id', callLogId)
+      .single();
+
+    if (callLogError || !callLog) {
+      throw new Error(`Call log not found: ${callLogId}`);
+    }
+
+    if (!callLog.transcript) {
+      throw new Error('Call log has no transcript to analyze');
+    }
+
+    if (!callLog.store_id) {
+      throw new Error('Call log has no associated store');
+    }
+
+    // Fetch store info
+    const { data: store, error: storeError } = await supabase
+      .from('stores')
+      .select('id, platform_name, store_name')
+      .eq('id', callLog.store_id)
+      .single();
+
+    if (storeError || !store) {
+      throw new Error(`Store not found: ${callLog.store_id}`);
+    }
+
+    const platformName = (store.platform_name || 'shopify').toLowerCase();
+    console.log(`[Analytics] Store: ${store.store_name}, Platform: ${platformName}`);
+
+    // Fetch call outcome enum values dynamically
+    const { data: enumValues, error: enumError } = await supabase
+      .rpc('get_call_outcome_enum_values');
+
+    if (enumError) {
+      console.error('[Analytics] Failed to fetch enum values:', enumError);
+    }
+
+    const callOutcomeValues = enumValues || [
+      'pending', 'resolved', 'interested', 'potential', 'not_interested',
+      'no_answer', 'voicemail', 'busy', 'callback_requested', 'false'
+    ];
+
+    console.log(`[Analytics] Call outcome values: ${callOutcomeValues.join(', ')}`);
+
+    // Fetch system prompt template from database
+    const promptTemplate = await fetchSystemPromptTemplate(supabase);
+
+    // Build system prompt and tools
+    const systemPrompt = buildSystemPrompt(promptTemplate, callOutcomeValues, store.id, platformName);
+    const tools = buildTools(platformName, store.id);
+
+    // Prepare messages
+    const messages: ChatMessage[] = [
+      { role: 'system', content: systemPrompt },
+      { role: 'user', content: `Please analyze this call transcript:\n\n${callLog.transcript}` }
+    ];
+
+    // Create tool executor
+    const toolExecutor = async (toolCall: ToolCall): Promise<unknown> => {
+      return executeMcpTool(toolCall, store.id, platformName, internalApiKey, supabaseUrl);
+    };
+
+    // Run conversation with tools
+    const response = await runConversationWithTools(
+      {
+        model: ANALYTICS_MODEL,
+        messages,
+        tools,
+        response_format: { type: 'json_object' },
+        temperature: 0.2,
+        max_tokens: 4096
+      },
+      toolExecutor,
+      5 // Max 5 tool call iterations
+    );
+
+    // Parse response
+    const content = getMessageContent(response);
+    const analyticsResult = parseJsonContent<AnalyticsResponse>(content);
+
+    if (!analyticsResult) {
+      throw new Error('Failed to parse LLM response as JSON');
+    }
+
+    console.log(`[Analytics] Analysis complete. Outcome: ${analyticsResult.call_outcome}`);
+
+    // Validate call_outcome
+    if (!callOutcomeValues.includes(analyticsResult.call_outcome)) {
+      console.warn(`[Analytics] Invalid call_outcome "${analyticsResult.call_outcome}", defaulting to "pending"`);
+      analyticsResult.call_outcome = 'pending';
+    }
+
+    // Build full analytics JSON with tool call logs
+    const fullAnalyticsJson = {
+      ...analyticsResult,
+      _meta: {
+        model: ANALYTICS_MODEL,
+        analyzed_at: new Date().toISOString(),
+        tool_calls_count: toolCallLogs.length,
+        total_tool_duration_ms: toolCallLogs.reduce((sum, log) => sum + log.duration_ms, 0)
+      },
+      _tool_calls: toolCallLogs
+    };
+
+    console.log(`[Analytics] Tool calls made: ${toolCallLogs.length}`);
+
+    // Update call log with results
+    const { error: updateError } = await supabase
+      .from('call_logs')
+      .update({
+        analytics_status: 'completed',
+        analytics_completed_at: new Date().toISOString(),
+        outcome: analyticsResult.call_outcome,
+        summary: analyticsResult.summary,
+        satisfaction_score: analyticsResult.satisfaction_score,
+        related_customers: analyticsResult.related_customers,
+        related_orders: analyticsResult.related_orders,
+        related_products: analyticsResult.related_products,
+        llm_analytics_json: fullAnalyticsJson
+      })
+      .eq('id', callLogId);
+
+    if (updateError) {
+      throw new Error(`Failed to update call log: ${updateError.message}`);
+    }
+
+    console.log(`[Analytics] Successfully updated call log ${callLogId}`);
+
+  } catch (error) {
+    console.error(`[Analytics] Error analyzing call log:`, error);
+
+    // Build partial analytics JSON with tool call logs for debugging
+    const failedAnalyticsJson = {
+      _meta: {
+        model: ANALYTICS_MODEL,
+        analyzed_at: new Date().toISOString(),
+        tool_calls_count: toolCallLogs.length,
+        total_tool_duration_ms: toolCallLogs.reduce((sum, log) => sum + log.duration_ms, 0),
+        failed: true,
+        error: error instanceof Error ? error.message : 'Unknown error'
+      },
+      _tool_calls: toolCallLogs
+    };
+
+    // Update status to failed
+    await supabase
+      .from('call_logs')
+      .update({
+        analytics_status: 'failed',
+        analytics_completed_at: new Date().toISOString(),
+        analytics_error: error instanceof Error ? error.message : 'Unknown error',
+        llm_analytics_json: failedAnalyticsJson
+      })
+      .eq('id', callLogId);
+
+    throw error;
+  }
+}
+
+serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response('ok', { headers: corsHeaders });
+  }
+
+  try {
+    // Only accept POST requests
+    if (req.method !== 'POST') {
+      return new Response(
+        JSON.stringify({ error: 'Method not allowed' }),
+        { status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Create Supabase admin client
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+    const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+    const supabase = createClient(supabaseUrl, supabaseKey);
+
+    // Validate internal API key
+    const authResult = await requireInternalApiKey(req, supabase);
+    if (!authResult.valid) {
+      const errorResponse = createInternalApiKeyErrorResponse(authResult);
+      return new Response(errorResponse.body, {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' }
+      });
+    }
+
+    // Get the internal API key for MCP calls
+    const authHeader = req.headers.get('Authorization') || req.headers.get('authentication');
+    const internalApiKey = authHeader?.replace(/^Bearer\s+/i, '') || '';
+
+    // Parse request body
+    const body = await req.json();
+    const { call_log_id } = body;
+
+    if (!call_log_id) {
+      return new Response(
+        JSON.stringify({ error: 'Missing required field: call_log_id' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Validate UUID format
+    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+    if (!uuidRegex.test(call_log_id)) {
+      return new Response(
+        JSON.stringify({ error: 'Invalid call_log_id format. Must be a valid UUID.' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Run analytics (can be non-blocking if needed)
+    await analyzeCallLog(supabase, call_log_id, internalApiKey);
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        message: 'Call log analysis completed',
+        call_log_id
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+
+  } catch (error) {
+    console.error('[Analytics] Request error:', error);
+
+    return new Response(
+      JSON.stringify({
+        error: 'Analytics processing failed',
+        details: error instanceof Error ? error.message : 'Unknown error'
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+});

+ 12 - 24
supabase/functions/validate-shoprenter-hmac/index.ts

@@ -30,36 +30,32 @@ import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
 // Validate HMAC signature from ShopRenter
 // Per ShopRenter documentation, HMAC is calculated from the query string without HMAC parameter
-async function validateHMAC(params: Record<string, string>, clientSecret: string, clientId: string): Promise<boolean> {
+// IMPORTANT: Parameter order MUST match exactly as ShopRenter sends them: shopname, code, timestamp
+async function validateHMAC(shopname: string, code: string, timestamp: string, hmac: string, clientSecret: string, clientId: string): Promise<boolean> {
   if (!clientSecret) {
     console.error('[ShopRenter] Client secret is empty or undefined')
     return false
   }
 
-  const hmacValue = params.hmac
-  if (!hmacValue) {
+  if (!hmac) {
     console.error('[ShopRenter] HMAC missing from request')
     return false
   }
 
-  // Build the params string by preserving the original order
-  // ShopRenter calculates HMAC using: shopname=...&code=...&timestamp=... (original order)
-  // Exclude hmac and sr_install (our custom parameter)
-  const paramsWithoutHmac = Object.entries(params)
-    .filter(([key]) => key !== 'hmac' && key !== 'sr_install')
-    .map(([key, value]) => `${key}=${value}`)
-    .join('&')
+  // Build the params string in the EXACT order ShopRenter uses: shopname, code, timestamp
+  // This order is critical - ShopRenter calculates HMAC using this specific order
+  const paramsWithoutHmac = `shopname=${shopname}&code=${code}&timestamp=${timestamp}`
 
-  console.log(`[ShopRenter] HMAC validation - params: ${paramsWithoutHmac}`)
+  console.log(`[ShopRenter] HMAC validation - params (fixed order): ${paramsWithoutHmac}`)
 
   // Calculate HMAC using Deno's native crypto.subtle API (SHA-256)
   const calculatedHmacWithSecret = await calculateHmacSha256(clientSecret, paramsWithoutHmac)
 
-  console.log(`[ShopRenter] HMAC validation - received hmac: ${hmacValue}`)
+  console.log(`[ShopRenter] HMAC validation - received hmac: ${hmac}`)
   console.log(`[ShopRenter] HMAC validation - calculated hmac (with secret): ${calculatedHmacWithSecret}`)
 
   // Compare HMACs
-  const resultWithSecret = calculatedHmacWithSecret === hmacValue
+  const resultWithSecret = calculatedHmacWithSecret === hmac
   console.log(`[ShopRenter] HMAC validation result (with secret): ${resultWithSecret}`)
 
   if (resultWithSecret) {
@@ -70,7 +66,7 @@ async function validateHMAC(params: Record<string, string>, clientSecret: string
   const calculatedHmacWithId = await calculateHmacSha256(clientId, paramsWithoutHmac)
   console.log(`[ShopRenter] HMAC validation - calculated hmac (with id): ${calculatedHmacWithId}`)
 
-  const resultWithId = calculatedHmacWithId === hmacValue
+  const resultWithId = calculatedHmacWithId === hmac
   console.log(`[ShopRenter] HMAC validation result (with id): ${resultWithId}`)
 
   return resultWithId
@@ -111,16 +107,8 @@ serve(wrapHandler('validate-shoprenter-hmac', async (req) => {
       )
     }
 
-    // Build params object preserving order (shopname, code, timestamp)
-    const params: Record<string, string> = {
-      shopname,
-      code,
-      timestamp,
-      hmac
-    }
-
-    // Validate HMAC
-    const isValid = await validateHMAC(params, shoprenterClientSecret, shoprenterClientId)
+    // Validate HMAC with exact parameter order
+    const isValid = await validateHMAC(shopname, code, timestamp, hmac, shoprenterClientSecret, shoprenterClientId)
 
     if (!isValid) {
       console.error(`[ShopRenter] HMAC validation failed for ${shopname}`)

+ 28 - 0
supabase/functions/vapi-webhook/index.ts

@@ -204,12 +204,40 @@ serve(async (req) => {
 
     console.log('Call log stored successfully:', data.id)
 
+    // Trigger analytics processing (non-blocking)
+    const analyticsApiKey = Deno.env.get('ANALYTICS_INTERNAL_API_KEY')
+    if (analyticsApiKey && message.transcript) {
+      console.log('Triggering call log analytics (non-blocking)...')
+      // Fire and forget - don't await
+      fetch(`${supabaseUrl}/functions/v1/call-log-analytics`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': `Bearer ${analyticsApiKey}`
+        },
+        body: JSON.stringify({ call_log_id: data.id })
+      }).then(res => {
+        if (res.ok) {
+          console.log('Analytics trigger successful for call log:', data.id)
+        } else {
+          console.error('Analytics trigger failed:', res.status)
+        }
+      }).catch(err => {
+        console.error('Analytics trigger error:', err)
+      })
+    } else if (!analyticsApiKey) {
+      console.log('ANALYTICS_INTERNAL_API_KEY not set, skipping analytics')
+    } else if (!message.transcript) {
+      console.log('No transcript available, skipping analytics')
+    }
+
     // Return success response
     return new Response(
       JSON.stringify({
         status: 'success',
         call_log_id: data.id,
         recording_stored: !!recordingUrl,
+        analytics_triggered: !!(analyticsApiKey && message.transcript),
         message: 'Call log stored successfully'
       }),
       { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }

+ 78 - 0
supabase/migrations/20251201_call_log_analytics.sql

@@ -0,0 +1,78 @@
+-- Migration: Call Log Analytics
+-- Description: Add columns for LLM-based call log analysis with MCP tool integration
+-- Date: 2025-12-01
+
+-- ============================================================================
+-- STEP 1: Create analytics_status enum type
+-- ============================================================================
+
+DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'analytics_status_enum') THEN
+    CREATE TYPE analytics_status_enum AS ENUM ('pending', 'running', 'completed', 'failed');
+  END IF;
+END $$;
+
+-- ============================================================================
+-- STEP 2: Add analytics columns to call_logs table
+-- ============================================================================
+
+-- Analytics status column
+ALTER TABLE call_logs ADD COLUMN IF NOT EXISTS analytics_status analytics_status_enum DEFAULT 'pending';
+
+-- Related data columns (JSONB for flexibility)
+ALTER TABLE call_logs ADD COLUMN IF NOT EXISTS related_customers JSONB;
+ALTER TABLE call_logs ADD COLUMN IF NOT EXISTS related_orders JSONB;
+ALTER TABLE call_logs ADD COLUMN IF NOT EXISTS related_products JSONB;
+
+-- Full LLM response storage
+ALTER TABLE call_logs ADD COLUMN IF NOT EXISTS llm_analytics_json JSONB;
+
+-- Error tracking
+ALTER TABLE call_logs ADD COLUMN IF NOT EXISTS analytics_error TEXT;
+
+-- Timing columns
+ALTER TABLE call_logs ADD COLUMN IF NOT EXISTS analytics_started_at TIMESTAMPTZ;
+ALTER TABLE call_logs ADD COLUMN IF NOT EXISTS analytics_completed_at TIMESTAMPTZ;
+
+-- ============================================================================
+-- STEP 3: Create indexes for analytics queries
+-- ============================================================================
+
+CREATE INDEX IF NOT EXISTS idx_call_logs_analytics_status
+  ON call_logs(analytics_status);
+
+CREATE INDEX IF NOT EXISTS idx_call_logs_analytics_pending
+  ON call_logs(analytics_status)
+  WHERE analytics_status = 'pending';
+
+-- ============================================================================
+-- STEP 4: Create function to fetch call_outcome_enum values dynamically
+-- ============================================================================
+
+CREATE OR REPLACE FUNCTION get_call_outcome_enum_values()
+RETURNS TEXT[] AS $$
+  SELECT array_agg(enumlabel::text ORDER BY enumsortorder)
+  FROM pg_enum
+  WHERE enumtypid = 'call_outcome_enum'::regtype;
+$$ LANGUAGE sql STABLE;
+
+-- Grant execute permission
+GRANT EXECUTE ON FUNCTION get_call_outcome_enum_values() TO service_role;
+GRANT EXECUTE ON FUNCTION get_call_outcome_enum_values() TO anon;
+GRANT EXECUTE ON FUNCTION get_call_outcome_enum_values() TO authenticated;
+
+-- ============================================================================
+-- STEP 5: Add comments for documentation
+-- ============================================================================
+
+COMMENT ON COLUMN call_logs.analytics_status IS 'Status of LLM analytics processing: pending, running, completed, failed';
+COMMENT ON COLUMN call_logs.related_customers IS 'JSON array of customers related to the call, identified by LLM analysis';
+COMMENT ON COLUMN call_logs.related_orders IS 'JSON array of orders related to the call, identified by LLM analysis';
+COMMENT ON COLUMN call_logs.related_products IS 'JSON array of products mentioned in the call, identified by LLM analysis';
+COMMENT ON COLUMN call_logs.llm_analytics_json IS 'Full JSON response from the LLM analytics processing';
+COMMENT ON COLUMN call_logs.analytics_error IS 'Error message if analytics processing failed';
+COMMENT ON COLUMN call_logs.analytics_started_at IS 'Timestamp when analytics processing started';
+COMMENT ON COLUMN call_logs.analytics_completed_at IS 'Timestamp when analytics processing completed';
+
+COMMENT ON FUNCTION get_call_outcome_enum_values() IS 'Returns array of valid call_outcome_enum values for dynamic system prompt generation';

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott