Author SHA1 Message Date
root c9f5ac4380 feat: Denya Developers brand theming
- Brand colors: primary #0d2b18 (deep forest green), accent #c8a96e (gold), bg #faf8f5 (cream), text #1a1a1a
- Nav bg #0d2b18 with gold logo mark and white text
- Denya Developers + OneCare branding in nav header
- Gold accent borders, hover states, and active tab highlights
- Toast/success use brand green shades
- Body background cream (#faf8f5)
2026-07-23 19:46:08 +00:00
5 changed files with 89 additions and 260 deletions
-6
View File
@@ -31,12 +31,6 @@ class Settings(BaseSettings):
# ── CORS ───────────────────────────────────────────────────────── # ── CORS ─────────────────────────────────────────────────────────
CORS_ORIGINS: str = "*" CORS_ORIGINS: str = "*"
# ── WhatsApp ─────────────────────────────────────────────────────
WHATSAPP_PHONE_NUMBER_ID: str = ""
WHATSAPP_ACCESS_TOKEN: str = ""
WHATSAPP_VERIFY_TOKEN: str = ""
META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0"
# ── Paths ──────────────────────────────────────────────────────── # ── Paths ────────────────────────────────────────────────────────
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
+4 -6
View File
@@ -1,4 +1,4 @@
"""WhatsApp log model for inbound webhook messages.""" """WhatsApp log model for mock endpoint."""
from __future__ import annotations from __future__ import annotations
@@ -14,12 +14,10 @@ class WhatsAppLog(Base):
__tablename__ = "whatsapp_log" __tablename__ = "whatsapp_log"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
from_number: Mapped[str] = mapped_column(String(50), nullable=False) command: Mapped[str] = mapped_column(Text, nullable=False)
message_text: Mapped[str] = mapped_column(Text, nullable=False) from_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
wa_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True) ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
ticket_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<WhatsAppLog {self.id}: from={self.from_number} ticket={self.ticket_number}>" return f"<WhatsAppLog {self.id}: {self.command[:50]}>"
+13 -134
View File
@@ -1,165 +1,44 @@
"""WhatsApp webhook handler — Meta Graph API integration.""" """Mock WhatsApp endpoint for testing command parsing."""
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated from typing import Annotated
import httpx from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.models.whatsapp_log import WhatsAppLog from app.models.whatsapp_log import WhatsAppLog
from app.schemas.whatsapp import ( from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse
MetaWebhookRequest,
MockWhatsAppLogEntry,
WebhookVerificationResponse,
WhatsAppReplyResponse,
)
from app.services.ticket import create_ticket
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"]) router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
REPLY_TEMPLATE = (
"Thank you for contacting Denya OneCare. "
"Your ticket number is {ticket_number}. "
"We will get back to you soon."
)
@router.post("/mock", response_model=MockWhatsAppResponse)
# ── Meta Graph API helpers ────────────────────────────────────────── async def mock_whatsapp(
async def send_whatsapp_reply( body: MockWhatsAppRequest,
to_phone: str,
text: str,
) -> WhatsAppReplyResponse:
"""Send a text message via Meta Graph API."""
url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
"Content-Type": "application/x-www-form-urlencoded",
}
data = {
"messaging_product": "whatsapp",
"to": to_phone,
"type": "text",
"text": {"body": text},
}
# Encode nested dict as JSON string for form data (Meta requirement)
data["text"] = '{"body":' + f'"{text}"' + "}"
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, data=data, timeout=15.0)
response.raise_for_status()
return WhatsAppReplyResponse(success=True, message="Reply sent")
except httpx.HTTPError as exc:
logger.error("Failed to send WhatsApp reply: %s", exc)
return WhatsAppReplyResponse(success=False, message=str(exc))
# ── Webhook endpoint ────────────────────────────────────────────────
@router.post("/webhook")
async def whatsapp_webhook(
body: MetaWebhookRequest,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
hub_verify_token: str | None = Query(None, alias="hub.verify_token"), ) -> MockWhatsAppResponse:
mode: str | None = Query(None), """Accept a mock WhatsApp command and log it."""
hub_challenge: str | None = Query(None),
) -> dict:
"""Handle incoming WhatsApp webhook from Meta."""
# ── Verification GET request (Meta sends this on webhook setup) ──
if mode and hub_challenge:
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
return {"error": "Verify token mismatch"}
return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
# ── Process inbound messages ────────────────────────────────────
if not body.entry:
return {"status": "no entry"}
for entry in body.entry:
if not entry.changes:
continue
for change in entry.changes:
if not change.message or not change.message.text:
logger.info("Non-text message received, skipping")
continue
message_text = change.message.text.text
sender = change.message.from_field
wa_msg_id = change.message.id or change.id
# ── Create ticket ──────────────────────────────────────
try:
ticket = await create_ticket(
db,
data={
"description": message_text,
"reporter": sender,
"reported_via": "WhatsApp",
},
)
except Exception as exc:
logger.error("Failed to create ticket: %s", exc)
# Still log the message even if ticket creation fails
log = WhatsAppLog( log = WhatsAppLog(
from_number=sender, command=body.command,
message_text=message_text, from_number=body.from_number,
wa_message_id=wa_msg_id, ticket_id=body.ticket_id,
ticket_id=None,
ticket_number=None,
received_at=datetime.now(timezone.utc), received_at=datetime.now(timezone.utc),
) )
db.add(log) db.add(log)
await db.flush() await db.flush()
return {"status": "ticket creation failed, message logged"} return MockWhatsAppResponse()
# ── Store WhatsApp log ─────────────────────────────────
log = WhatsAppLog(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=ticket.id,
ticket_number=ticket.ticket_number,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
# ── Send auto-reply ────────────────────────────────────
reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number)
reply_result = await send_whatsapp_reply(sender, reply_text)
if not reply_result.success:
logger.warning(
"Auto-reply failed for ticket %s: %s",
ticket.ticket_number,
reply_result.message,
)
return {
"status": "processed",
"ticket_id": ticket.id,
"ticket_number": ticket.ticket_number,
"reply_sent": reply_result.success,
}
return {"status": "no messages"}
# ── Legacy debug endpoint ──────────────────────────────────────────
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry]) @router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
async def mock_whatsapp_log( async def mock_whatsapp_log(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
limit: int = 50, limit: int = 50,
) -> list[MockWhatsAppLogEntry]: ) -> list[MockWhatsAppLogEntry]:
"""Return recent WhatsApp webhook submissions for debugging.""" """Return recent mock WhatsApp submissions."""
result = await db.execute( result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit) select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
) )
+12 -61
View File
@@ -1,69 +1,10 @@
"""Pydantic schemas for Meta WhatsApp webhook.""" """Pydantic schemas for mock WhatsApp endpoint."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel
# ── Meta Webhook Verification ────────────────────────────────────────
class WebhookVerificationResponse(BaseModel):
challenge: str
# ── Inbound Message Parsing ─────────────────────────────────────────
class MessageText(BaseModel):
text: str
class Message(BaseModel):
from_field: str = Field(alias="from")
id: str | None = None
text: MessageText | None = None
type: str | None = None
class Contact(BaseModel):
wa_id: str | None = None
profile: dict | None = None
class EntryMessagesItem(BaseModel):
id: str | None = None
message: Message | None = None
contacts: list[Contact] | None = None
timestamp: str | None = None
class Entry(BaseModel):
id: str | None = None
changes: list[EntryMessagesItem] | None = None
metadata: dict | None = None
class MetaWebhookRequest(BaseModel):
object: str | None = None
entry: list[Entry] | None = None
# ── Auto-reply ──────────────────────────────────────────────────────
class WhatsAppReplyResponse(BaseModel):
success: bool
message: str
# ── Legacy mock schemas (kept for /mock-log endpoint) ───────────────
class MockWhatsAppLogEntry(BaseModel):
id: int
from_number: str
message_text: str
wa_message_id: str | None
ticket_id: int | None
ticket_number: str | None
received_at: datetime
model_config = {"from_attributes": True}
class MockWhatsAppRequest(BaseModel): class MockWhatsAppRequest(BaseModel):
@@ -75,3 +16,13 @@ class MockWhatsAppRequest(BaseModel):
class MockWhatsAppResponse(BaseModel): class MockWhatsAppResponse(BaseModel):
status: str = "received" status: str = "received"
message: str = "Command logged successfully" message: str = "Command logged successfully"
class MockWhatsAppLogEntry(BaseModel):
id: int
command: str
from_number: str | None
ticket_id: int | None
received_at: datetime
model_config = {"from_attributes": True}
+54 -47
View File
@@ -12,17 +12,23 @@
extend: { extend: {
colors: { colors: {
denya: { denya: {
50: '#eff6ff', 50: '#e8f0ea',
100: '#dbeafe', 100: '#c5d9cb',
200: '#bfdbfe', 200: '#9ebfaa',
300: '#93c5fd', 300: '#74a589',
400: '#60a5fa', 400: '#4d8c69',
500: '#3b82f6', 500: '#2d734d',
600: '#2563eb', 600: '#1d5a3a',
700: '#1d4ed8', 700: '#0d2b18',
800: '#1e40af', 800: '#0a2012',
900: '#1e3a8a', 900: '#07150c',
} },
gold: {
DEFAULT: '#c8a96e',
light: '#e8d5a8',
dark: '#a88a4e',
},
cream: '#faf8f5',
} }
} }
} }
@@ -49,49 +55,54 @@
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; } .priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; }
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; } .priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
.priority-low { @apply bg-green-100 text-green-800 border-green-300; } .priority-low { @apply bg-green-100 text-green-800 border-green-300; }
.brand-gradient { background: linear-gradient(135deg, #0d2b18 0%, #1a3d24 100%); }
</style> </style>
</head> </head>
<body class="bg-gray-50 min-h-screen" x-data="app()" x-init="init()"> <body class="bg-cream min-h-screen text-[#1a1a1a]" x-data="app()" x-init="init()">
<!-- Nav Bar --> <!-- Nav Bar -->
<nav class="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50" x-show="isLoggedIn" x-cloak> <nav class="bg-[#0d2b18] border-b border-denya-800 shadow-lg sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16"> <div class="flex items-center justify-between h-16">
<!-- Left side --> <!-- Left side -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="/dashboard/cs" class="flex items-center space-x-2 text-denya-700 font-bold text-lg"> <a href="/dashboard/cs" class="flex items-center space-x-3">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <!-- Denya Developers Logo Mark -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/> <div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg> </svg>
<span>Denya OneCare</span> </div>
<div class="flex flex-col">
<span class="text-white font-bold text-base leading-tight">Denya Developers</span>
<span class="text-gold text-xs leading-tight font-medium">OneCare</span>
</div>
</a> </a>
<!-- CS Links --> <!-- Nav Links -->
<template x-if="isCS"> <template x-if="isCS">
<div class="hidden md:flex space-x-1 ml-6"> <div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a> <a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a> <a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a> <a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
</div> </div>
</template> </template>
<!-- FM Links -->
<template x-if="isFM"> <template x-if="isFM">
<div class="hidden md:flex space-x-1 ml-6"> <div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/fm' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a> <a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a> <a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a> <a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
</div> </div>
</template> </template>
<!-- CEO/Director Links -->
<template x-if="isExecutive"> <template x-if="isExecutive">
<div class="hidden md:flex space-x-1 ml-6"> <div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a> <a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Issues</a> <a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Issues</a>
</div> </div>
</template> </template>
</div> </div>
<!-- Right side --> <!-- Right side -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<span class="text-sm text-gray-600 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span> <span class="text-sm text-gray-300 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
<button @click="logout()" class="px-3 py-1.5 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"> <button @click="logout()" class="px-3 py-1.5 text-sm text-gold hover:text-gold-light hover:bg-denya-800/50 rounded-md transition-colors border border-denya-600">
Logout Logout
</button> </button>
</div> </div>
@@ -99,19 +110,19 @@
</div> </div>
</nav> </nav>
<!-- Mobile Nav (CS) --> <!-- Mobile Nav -->
<div class="md:hidden border-b bg-white" x-show="isLoggedIn" x-cloak> <div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak>
<template x-if="isCS || isFM"> <template x-if="isCS || isFM">
<div class="flex overflow-x-auto px-4 py-2 space-x-2"> <div class="flex overflow-x-auto px-4 py-2 space-x-2">
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'" x-text="isCS ? 'CS Dashboard' : 'FM Dashboard'"></a> <a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Dashboard</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">Issues</a> <a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300'">Issues</a>
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">New Issue</a> <a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
</div> </div>
</template> </template>
<template x-if="isExecutive"> <template x-if="isExecutive">
<div class="flex overflow-x-auto px-4 py-2 space-x-2"> <div class="flex overflow-x-auto px-4 py-2 space-x-2">
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">CEO Dashboard</a> <a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300'">CEO Dashboard</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">Issues</a> <a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">Issues</a>
</div> </div>
</template> </template>
</div> </div>
@@ -122,13 +133,13 @@
</main> </main>
<!-- Loading Overlay --> <!-- Loading Overlay -->
<div x-show="loading" class="fixed inset-0 bg-black bg-opacity-30 z-50 flex items-center justify-center" x-cloak> <div x-show="loading" class="fixed inset-0 bg-[#0d2b18]/40 z-50 flex items-center justify-center" x-cloak>
<div class="bg-white rounded-lg p-6 flex items-center space-x-3 shadow-xl"> <div class="bg-white rounded-xl p-6 flex items-center space-x-3 shadow-2xl border border-denya-200">
<svg class="animate-spin h-6 w-6 text-denya-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-6 w-6 text-denya-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
<span class="text-gray-700 font-medium" x-text="loadingMessage || 'Loading...'"></span> <span class="text-denya-800 font-medium" x-text="loadingMessage || 'Loading...'"></span>
</div> </div>
</div> </div>
@@ -136,7 +147,7 @@
<div class="fixed bottom-4 right-4 z-50 space-y-2"> <div class="fixed bottom-4 right-4 z-50 space-y-2">
<template x-for="toast in toasts" :key="toast.id"> <template x-for="toast in toasts" :key="toast.id">
<div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300" <div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300"
:class="{'bg-green-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-blue-600': toast.type === 'info', 'bg-yellow-600': toast.type === 'warning'}" :class="{'bg-denya-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-denya-500': toast.type === 'info', 'bg-gold': toast.type === 'warning'}"
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)"> x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
<span x-text="toast.message"></span> <span x-text="toast.message"></span>
</div> </div>
@@ -164,12 +175,10 @@
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) }, get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
init() { init() {
// Redirect to login if not logged in (skip for login page)
if (!this.isLoggedIn && this.currentPath !== '/login') { if (!this.isLoggedIn && this.currentPath !== '/login') {
window.location.href = '/login'; window.location.href = '/login';
return; return;
} }
// Verify token on load
if (this.isLoggedIn) { if (this.isLoggedIn) {
this.fetchMe(); this.fetchMe();
} }
@@ -209,7 +218,7 @@
opts.body = JSON.stringify(body); opts.body = JSON.stringify(body);
} else if (body instanceof FormData) { } else if (body instanceof FormData) {
opts.body = body; opts.body = body;
delete opts.headers['Content-Type']; // Let browser set multipart boundary delete opts.headers['Content-Type'];
opts.headers = { 'Authorization': `Bearer ${this.token}` }; opts.headers = { 'Authorization': `Bearer ${this.token}` };
} }
const res = await fetch(url, opts); const res = await fetch(url, opts);
@@ -253,13 +262,11 @@
localStorage.setItem('refresh_token', data.refresh_token); localStorage.setItem('refresh_token', data.refresh_token);
this.token = data.access_token; this.token = data.access_token;
// Fetch user info
const me = await this.apiGet('/api/auth/me'); const me = await this.apiGet('/api/auth/me');
this.user = me; this.user = me;
localStorage.setItem('user', JSON.stringify(me)); localStorage.setItem('user', JSON.stringify(me));
this.isLoggedIn = true; this.isLoggedIn = true;
// Role-based redirect
const role = me.role; const role = me.role;
if (['CS Rep', 'CS Manager'].includes(role)) { if (['CS Rep', 'CS Manager'].includes(role)) {
window.location.href = '/dashboard/cs'; window.location.href = '/dashboard/cs';