Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,15 @@ MOCK_PAYMENT_WEBHOOK_SECRET=local-development-mock-webhook-secret
FLUTTERWAVE_BASE_URL=https://api.flutterwave.com/v3
FLUTTERWAVE_SECRET_KEY=
FLUTTERWAVE_WEBHOOK_HASH=
# F.A.T.U Concierge (AI customer care). Keys are server-only — never NEXT_PUBLIC_*.
# Default provider is mock (deterministic grounded answers) so CI works without secrets.
AI_ENABLED=true
AI_PROVIDER=mock
AI_MODEL=gpt-4.1-mini
AI_API_KEY=
AI_FALLBACK_MODEL=
AI_MAX_DAILY_REQUESTS=2000
AI_KNOWLEDGE_SYNC_SECRET=
AI_SUPPORT_EMAIL=
AI_CHAT_RETENTION_DAYS=90
AI_ASSISTANT_NAME=F.A.T.U Concierge
11 changes: 11 additions & 0 deletions apps/admin/app/concierge/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import { ConciergeAdminView } from '@/components/concierge-admin-view';

export const metadata: Metadata = {
title: 'Concierge',
description: 'AI customer-care conversations, tickets, knowledge, and metrics.',
};

export default function ConciergeAdminPage() {
return <ConciergeAdminView />;
}
2 changes: 2 additions & 0 deletions apps/admin/components/admin-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BarChart3,
Boxes,
Factory,
Headset,
LayoutDashboard,
Menu,
Palette,
Expand All @@ -25,6 +26,7 @@ const NAV = [
{ href: '/orders', label: 'Orders', icon: ShoppingBag },
{ href: '/artworks', label: 'Artworks', icon: Palette },
{ href: '/storyteller', label: 'Brand Storyteller', icon: Sparkles },
{ href: '/concierge', label: 'Concierge', icon: Headset },
{ href: '/garments', label: 'Garments', icon: Boxes },
{ href: '/production', label: 'Production', icon: Factory },
{ href: '/customers', label: 'Customers', icon: Users },
Expand Down
241 changes: 241 additions & 0 deletions apps/admin/components/concierge-admin-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
'use client';

import { Heading, Text } from '@tms/ui';
import { useEffect, useState } from 'react';

interface Metrics {
totalConversations: number;
ticketsByStatus: Record<string, number>;
eventsByType: Record<string, number>;
retentionDays: number;
assistantName: string;
}

interface TicketRow {
reference: string;
category: string;
priority: string;
status: string;
summary: string;
createdAt: string;
}

interface KnowledgeRow {
title: string;
sourceType: string;
canonicalUrl: string;
published: boolean;
lastSyncedAt: string | null;
syncError: string | null;
chunkCount: number;
}

interface ConversationRow {
publicId: string;
status: string;
intent: string | null;
messageCount: number;
updatedAt: string;
}

const DEMO_METRICS: Metrics = {
totalConversations: 0,
ticketsByStatus: { OPEN: 0 },
eventsByType: {},
retentionDays: 90,
assistantName: 'F.A.T.U Concierge',
};

const DEMO_KNOWLEDGE: KnowledgeRow[] = [
{
title: 'Delivery',
sourceType: 'POLICY',
canonicalUrl: '/delivery',
published: true,
lastSyncedAt: null,
syncError: null,
chunkCount: 1,
},
{
title: 'Returns & exchanges',
sourceType: 'POLICY',
canonicalUrl: '/returns',
published: true,
lastSyncedAt: null,
syncError: null,
chunkCount: 1,
},
{
title: 'Size guide',
sourceType: 'POLICY',
canonicalUrl: '/size-guide',
published: true,
lastSyncedAt: null,
syncError: null,
chunkCount: 1,
},
];

function apiBase(): string {
return (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000').replace(/\/$/, '');
}

async function adminFetch<T>(path: string): Promise<T | null> {
try {
const response = await fetch(`${apiBase()}${path}`, {
credentials: 'include',
cache: 'no-store',
});
if (!response.ok) return null;
const json = (await response.json()) as { data: T };
return json.data;
} catch {
return null;
}
}

/**
* Concierge operations console. Loads live admin endpoints when the API is
* reachable; otherwise shows an honest preview seeded from the public knowledge
* corpus (no fabricated conversation volumes).
*/
export function ConciergeAdminView() {
const [metrics, setMetrics] = useState<Metrics>(DEMO_METRICS);
const [tickets, setTickets] = useState<TicketRow[]>([]);
const [knowledge, setKnowledge] = useState<KnowledgeRow[]>(DEMO_KNOWLEDGE);
const [conversations, setConversations] = useState<ConversationRow[]>([]);
const [live, setLive] = useState(false);

useEffect(() => {
void (async () => {
const [m, t, k, c] = await Promise.all([
adminFetch<Metrics>('/api/v1/concierge/admin/metrics'),
adminFetch<TicketRow[]>('/api/v1/concierge/admin/tickets'),
adminFetch<KnowledgeRow[]>('/api/v1/concierge/admin/knowledge'),
adminFetch<ConversationRow[]>('/api/v1/concierge/admin/conversations'),
]);
if (m || t || k || c) {
setLive(true);
if (m) setMetrics(m);
if (t) setTickets(t);
if (k) setKnowledge(k);
if (c) setConversations(c);
}
})();
}, []);

return (
<div className="space-y-10">
<header className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">Customer care</p>
<Heading as={1} className="font-display text-3xl text-ink">
F.A.T.U Concierge
</Heading>
<Text className="max-w-2xl text-muted">
Conversations, support tickets, knowledge sync, and assisted-care metrics. API keys are
never shown here.
</Text>
{!live ? (
<p className="rounded-[var(--radius-md)] border border-line bg-canvas-2 px-3 py-2 text-xs text-muted">
Preview mode — connect the API with an admin session to load live queues. Knowledge
listed below mirrors the storefront seed corpus.
</p>
) : null}
</header>

<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="Assistant" value={metrics.assistantName} />
<MetricCard label="Conversations" value={String(metrics.totalConversations)} />
<MetricCard label="Retention (days)" value={String(metrics.retentionDays)} />
<MetricCard label="Open tickets" value={String(metrics.ticketsByStatus.OPEN ?? 0)} />
</section>

<section className="space-y-3">
<Heading as={2} className="text-xl text-ink">
Support ticket queue
</Heading>
{tickets.length === 0 ? (
<p className="text-sm text-muted">No tickets yet.</p>
) : (
<div className="overflow-x-auto rounded-[var(--radius-md)] border border-line">
<table className="min-w-full text-left text-sm">
<thead className="bg-canvas-2 text-xs uppercase tracking-wide text-muted">
<tr>
<th className="px-3 py-2">Reference</th>
<th className="px-3 py-2">Priority</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Category</th>
<th className="px-3 py-2">Summary</th>
</tr>
</thead>
<tbody>
{tickets.map((ticket) => (
<tr key={ticket.reference} className="border-t border-line">
<td className="px-3 py-2 font-medium text-ink">{ticket.reference}</td>
<td className="px-3 py-2">{ticket.priority}</td>
<td className="px-3 py-2">{ticket.status}</td>
<td className="px-3 py-2">{ticket.category}</td>
<td className="px-3 py-2 text-muted">{ticket.summary}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>

<section className="space-y-3">
<Heading as={2} className="text-xl text-ink">
Recent conversations
</Heading>
{conversations.length === 0 ? (
<p className="text-sm text-muted">No stored conversations yet.</p>
) : (
<ul className="divide-y divide-line rounded-[var(--radius-md)] border border-line">
{conversations.map((c) => (
<li
key={c.publicId}
className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm"
>
<span className="font-medium text-ink">{c.publicId}</span>
<span className="text-muted">
{c.intent ?? '—'} · {c.messageCount} msgs · {c.status}
</span>
</li>
))}
</ul>
)}
</section>

<section className="space-y-3">
<Heading as={2} className="text-xl text-ink">
Knowledge sources
</Heading>
<ul className="space-y-2">
{knowledge.map((row) => (
<li
key={`${row.sourceType}-${row.title}`}
className="rounded-[var(--radius-md)] border border-line px-3 py-2 text-sm"
>
<p className="font-medium text-ink">{row.title}</p>
<p className="text-xs text-muted">
{row.sourceType} · {row.chunkCount} chunks ·{' '}
{row.published ? 'published' : 'unpublished'}
{row.canonicalUrl ? ` · ${row.canonicalUrl}` : ''}
</p>
</li>
))}
</ul>
</section>
</div>
);
}

function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[var(--radius-md)] border border-line bg-canvas-2 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-muted">{label}</p>
<p className="mt-1 font-display text-xl text-ink">{value}</p>
</div>
);
}
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ArtworkModule } from './artworks/artwork.module.js';
import { CatalogueModule } from './catalogue/catalogue.module.js';
import { AuthModule } from './auth/auth.module.js';
import { CartModule } from './cart/cart.module.js';
import { ConciergeModule } from './concierge/concierge.module.js';
import { DesignModule } from './designs/design.module.js';
import { HealthController } from './health/health.controller.js';
import { HealthService } from './health/health.service.js';
Expand All @@ -24,6 +25,7 @@ import { PaymentModule } from './payments/payment.module.js';
CatalogueModule,
GarmentModule,
CartModule,
ConciergeModule,
DesignModule,
InventoryModule,
MediaModule,
Expand Down
Loading
Loading