Skip to Content

UI Layer

Three Applications

The monorepo contains three separate Next.js applications, each serving a different audience:

AppAudiencePathPortThemingPurpose
DashboardEnd clients / tenantsapps/dashboard:3000[Live]Tenant-facing: campaigns, deliverables, reports, costs, approvals
DM PortalDigital marketers & HITL reviewersapps/dm:3002[Live]Internal team: review content, manage activities, oversee agents, approve/reject
ManageSuper admins (platform team)apps/manage:3001[Live]Platform administration: tenant management, master data, global settings, billing

All three are Next.js App Router apps sharing the same packages/ ecosystem. They are deployed as separate services in Coolify.


Technology Stack

ConcernTechnologyNotes
FrameworkNext.js 15 (App Router)Server Components, API Routes, Streaming RSC
LanguageTypeScript 5.x strictShared types from packages/shared
StylingTailwind CSS v4Design tokens, utility-first
Component libraryshadcn/uiRadix UI primitives, copy-owned components
Server stateTanStack Query v5Polling, SSE, cache invalidation
FormsReact Hook Form + ZodSchema-validated, type-safe
TablesTanStack Table v8Sortable, filterable, paginated
ChartsRechartsCost dashboard, analytics, activity graphs
Markdown renderreact-markdown + rehype-highlightDeliverable content display
Rich text editingTipTapInline editing of deliverables in approvals
IconsLucide React + react-iconsLucide for all UI icons; react-icons/fa6, fa, si for brand/platform icons
Real-timeServer-Sent EventsLive agent output, activity status updates
AuthFastify JWT (HS256) via @leadmetrics/middlewareJWT sessions, tenant resolution, RBAC
EmailSendGridNotifications, approval alerts, report delivery
Monorepo buildTurborepoParallel builds, shared cache

App Routing & Tenant Resolution

In the SaaS deployment, tenant is resolved from subdomain before any page renders:

// middleware.ts (shared across all three apps) export async function middleware(request: NextRequest) { const host = request.headers.get('host') ?? ''; const subdomain = host.split('.')[0]; // Map subdomain → tenantId const tenant = await getTenantBySlug(subdomain); if (!tenant) return NextResponse.redirect('/404'); // Inject tenant context into request headers (picked up by Server Components) const response = NextResponse.next(); response.headers.set('x-tenant-id', tenant.id); response.headers.set('x-tenant-slug', tenant.slug); return response; }

In enterprise on-prem (single-tenant), the middleware reads from SINGLE_TENANT_ID env var instead of the subdomain.


Dashboard App (apps/dashboard)

Audience: End client users — the marketing team members of each tenant.

URL structure:

/dashboard Home — activity feed, stat overview [Live] /strategy Marketing strategy document [Live] /goals Goals list [Live] /leads Leads list [Live] /deliverables Deliverables list [Live] /activities Activities list [Live] /calendar Activity calendar [Live] /blog Blog posts list [Live] /blog/[id] Blog post detail / review [Live] /social Social posts list [Live] /social/[id] Social post detail [Live] /channels OAuth-connected publishing channels [Live] /reports Reports list [Live] /client-info Business info [Live] /audit-log Activity log (audit trail) [Live] /settings/agents Agent configuration for this tenant [Live] /settings/agents/[id] Agent detail + run stats [Live] /settings/users Tenant users [Live] /settings/profile User profile [Live] /settings/notifications Notification preferences [Live] /settings/context Context approval [Live] /chat Agent Chat [Live]

See screens-dashboard.md for full screen specifications.

Key characteristics:

  • Primarily read-heavy with targeted write actions (submit campaign, approve/reject)
  • Server Components for campaign lists and deliverable renders — no data fetching on client
  • SSE connections for live activity updates and agent output streaming
  • Tenant-scoped: users only see their own tenant’s data

DM Portal App (apps/dm)

Audience: Internal digital marketers and human-in-the-loop reviewers. Port: :3002 Auth: Cookie-based (dm_access_token / dm_refresh_token). Calls /auth/v1/login with app: "dm". JWT verified server-side in the authenticated layout. Roles allowed: reviewer, admin, super_admin.

URL structure:

/overview Mission Control — pending approvals, running agents [Live] /approvals Cross-tenant approvals queue [Live] /activities All activities across tenants (filterable) [Live] /calendar Activity calendar [Live] /blog Blog posts list (cross-tenant) [Live] /blog/[id] Blog post detail / review [Live] /social Social posts list (cross-tenant) [Live] /social/[id] Social post detail [Live] /strategy Strategy viewer [Live] /goals Goals across tenants [Live] /deliverables Deliverables list [Live] /activity-log Audit / activity log [Live] /agents Agent management (DM-scoped) [Live] /agents/[id] Agent detail + run stats [Live] /users DM user list [Live] /reports Performance reports [Live] /requests/new Ad-hoc request form [Live] /profile DM user profile [Live] / Redirects → /overview

Key characteristics:

  • Cross-tenant visibility — a DM reviewer sees pending approvals across all their assigned tenants
  • Activity-focused: creating tasks, monitoring execution, completing and reporting
  • Highest-density approval workflow — optimised for reviewing many items quickly
  • Real-time: SSE streams for live agent output and new approval notifications

Manage App (apps/manage)

Audience: Platform super-admins (the agency ops team / platform engineering). Port: :3001 Auth: Cookie-based (manage_access_token / manage_refresh_token). Calls /auth/v1/login with app: "manage". JWT verified server-side in the authenticated layout. Role required: super_admin only.

URL structure:

/overview Platform overview — all tenants, system health [Live] /tenants Tenant list [Live] /tenants/[id] Tenant detail: billing, config, users, agents [Live] /invoices Global invoices list [Live] /plans Subscription plans [Live] /users Global users list [Live] /agents Global agent config [Live] /agents/[id] Agent detail + run stats + skills [Live] /skills Global skills library [Live] /templates/email Email templates [Live] /templates/telegram Telegram templates [Live] /audit-logs Audit log (cross-tenant) [Live] /system/rag-config RAG & AI Config [Live]

See docs/apps/manage/screens.md for full screen specifications.

Key characteristics:

  • Super-admin only — separate auth check (role: super_admin)
  • Read-heavy global view with targeted write actions
  • No SSE streams needed — polling is sufficient at this level
  • Manages the master data that all tenants inherit

Shared Component Patterns

Agent icon vs. User icon

Throughout all three apps, principals are displayed with a distinguishing icon:

// packages/shared/src/components/PrincipalAvatar.tsx export function PrincipalAvatar({ principal }: { principal: Principal }) { if (principal.type === 'agent') { return ( <div className="flex items-center justify-center w-8 h-8 rounded-full bg-indigo-100"> <Bot className="w-4 h-4 text-indigo-600" /> {/* Lucide Bot icon */} </div> ); } return ( <div className="flex items-center justify-center w-8 h-8 rounded-full bg-slate-100"> <User className="w-4 h-4 text-slate-600" /> {/* Lucide User icon */} </div> ); }

Activity/task status badge

Consistent across all three apps. “Activity” is the user-facing term; “task” is the internal code term.

const STATUS_CONFIG = { pending: { label: 'Pending', color: 'slate', pulse: false }, running: { label: 'Running', color: 'blue', pulse: true }, completed: { label: 'Completed', color: 'emerald', pulse: false }, failed: { label: 'Failed', color: 'red', pulse: false }, retrying: { label: 'Retrying', color: 'amber', pulse: true }, awaiting_approval: { label: 'Awaiting Review', color: 'purple', pulse: false }, needs_review: { label: 'Needs Review', color: 'orange', pulse: false }, paused: { label: 'Paused', color: 'slate', pulse: false }, };

Real-time Architecture

Real-time updates are delivered via Socket.IO (connected to the Fastify API service). All three topbars connect via connectSocket() and listen for events.

Notification events (notification:new): New notifications pushed to the client in real time. The topbar bell badge increments without a page refresh.

// All three topbars subscribe to this on mount void connectSocket().then((socket) => { socket.on("notification:new", (notif) => { setNotifications((prev) => [{ ...notif, read: false }, ...prev]); }); });

Authentication (JWT)

HS256-signed JWTs issued by the Fastify API (/auth/v1/login). Each portal has its own httpOnly cookie pair and validates JWTs locally via createJwtAuthMiddleware from @leadmetrics/middleware. Server components and actions use requireSession() / requireAuth() / requireSuperAdmin() from @/lib/server-auth.

App-level access gates:

  • apps/dashboard — tenant users; cookie: dashboard_access_token
  • apps/dm — roles: reviewer, admin, super_admin; cookie: dm_access_token
  • apps/manage — role: super_admin only; cookie: manage_access_token

Design System

See design-system.md for the full color palette, typography, spacing, component patterns, and CSS variable definitions.

© 2026 Leadmetrics — Internal use only