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
32 changes: 21 additions & 11 deletions packages/app/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,8 @@ app.use('/api/onboard', onboardRouter);

app.use('/gateway', gatewayRouter);

// ─── Static file serving (production) ────────────────────────────────────────
// ─── API/gateway 404 ─────────────────────────────────────────────────────────

const webRoot = path.resolve(process.cwd(), 'web', 'out');
app.use(express.static(webRoot));

// ─── Fallback ─────────────────────────────────────────────────────────────────

// API/gateway 404
app.use('/api', (_req, res) => {
res.status(404).json({ ok: false, error: 'Not found' });
});
Expand All @@ -75,10 +69,26 @@ app.use('/gateway', (_req, res) => {
res.status(404).json({ ok: false, error: 'Not found' });
});

// SPA fallback — serve index.html for all other routes
app.use((_req, res) => {
res.sendFile(path.join(webRoot, 'index.html'));
});
// ─── Frontend serving ────────────────────────────────────────────────────────

const isDev = process.env.NODE_ENV !== 'production';
const webRoot = path.resolve(process.cwd(), 'web/dist');

if (isDev) {
// Embed Vite dev server with HMR directly into Express
const { createServer: createViteServer } = await import('vite');
const vite = await createViteServer({
configFile: path.resolve(process.cwd(), 'web/vite.config.ts'),
server: { middlewareMode: true },
});
app.use(vite.middlewares);
} else {
// Production: serve the static build
app.use(express.static(webRoot));
app.use((_req, res) => {
res.sendFile(path.join(webRoot, 'index.html'));
});
}

app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
Expand Down
6 changes: 6 additions & 0 deletions packages/app/api/routes/tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const updateSettingsSchema = z.object({
name: z.string().min(2).max(80).optional(),
settings: z
.object({
siteTitle: z.string().max(100).optional(),
personaName: z.string().min(1).max(80).optional(),
personaPrompt: z.string().max(4000).optional(),
endUserAccess: z.enum(['anonymous', 'whitelist', 'blacklist']).optional(),
Expand All @@ -26,6 +27,7 @@ const updateSettingsSchema = z.object({
multimodal: z.boolean().optional(),
}).nullable().optional(),
}).optional(),
defaultHomePage: z.string().max(100).nullable().optional(),
onboarding: z.object({
headline: z.string().max(200).optional(),
subtitle: z.string().max(400).optional(),
Expand Down Expand Up @@ -59,6 +61,10 @@ tenantRouter.patch('/', requireAdmin, validate(updateSettingsSchema), async (req
if (body.settings != null) {
const { clawscale, onboarding, ...flatSettings } = body.settings;
updatedSettings = { ...updatedSettings, ...flatSettings };
// Remove flat keys explicitly set to null
for (const [key, val] of Object.entries(flatSettings)) {
if (val === null) delete updatedSettings[key];
}
if (onboarding !== undefined) {
const existingOb = (updatedSettings.onboarding as Record<string, unknown>) ?? {};
updatedSettings.onboarding = { ...existingOb, ...onboarding };
Expand Down
13 changes: 7 additions & 6 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 tsx watch api/index.ts",
"dev:web": "next dev web --port 4040",
"build": "tsc -p tsconfig.json && next build web",
"dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 tsx api/index.ts",
"dev:web": "vite --config web/vite.config.ts",
"build": "tsc -p tsconfig.json && vite build --config web/vite.config.ts",
"start": "node dist/api/index.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
Expand Down Expand Up @@ -40,20 +40,20 @@
"matrix-js-sdk": "^41.2.0",
"morgan": "^1.10.1",
"nanoid": "^5.1.5",
"next": "16.2.1",
"openai": "^6.33.0",
"pino": "^10.3.1",
"qrcode": "^1.5.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-router-dom": "^7.14.0",
"slugify": "^1.6.6",
"tailwind-merge": "^3.5.0",
"ws": "^8.20.0",
"xlsx": "^0.18.5",
"zod": "^3.24.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/vite": "^4.2.2",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.9",
Expand All @@ -63,14 +63,15 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^3.2.4",
"cross-env": "^10.1.0",
"eslint": "^10",
"eslint-config-next": "16.2.1",
"prisma": "^6.6.0",
"tailwindcss": "^4",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vite": "^8.0.3",
"vitest": "^3.1.3"
}
}
4 changes: 4 additions & 0 deletions packages/app/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export interface OnboardingBranding {
}

export interface TenantSettings {
/** Custom browser tab title (falls back to tenant name if not set) */
siteTitle?: string;
/** Display name for the AI persona shown to end-users */
personaName: string;
/** System prompt that defines the bot's behaviour */
Expand All @@ -69,6 +71,8 @@ export interface TenantSettings {
clawscale?: ClawScaleAgentSettings;
/** Onboarding portal branding (consumer-facing page) */
onboarding?: OnboardingBranding;
/** Override the default landing page for the root URL (e.g. "/onboard") */
defaultHomePage?: string;
}

export type AiBackendType = 'llm' | 'openclaw' | 'palmos' | 'claude-code' | 'custom' | 'cli-bridge';
Expand Down
7 changes: 3 additions & 4 deletions packages/app/web/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
# Web App

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
Vite + React SPA with React Router for routing and Tailwind CSS v4 for styling.
Express serves the built static files from `web/dist` in production.
18 changes: 0 additions & 18 deletions packages/app/web/app/layout.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter, usePathname } from 'next/navigation';
import Image from 'next/image';
import { Link, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, UserCheck, Radio, Settings, LogOut, MessageSquare, BotMessageSquare, Globe } from 'lucide-react';
import { isAuthenticated, clearAuth, getUser, getTenant } from '@/lib/auth';
import { cn } from '@/lib/utils';

const navItems: { href: string; icon: typeof LayoutDashboard; label: string; exact?: boolean }[] = [
{ href: '/', icon: LayoutDashboard, label: 'Dashboard', exact: true },
{ href: '/dashboard', icon: LayoutDashboard, label: 'Dashboard', exact: true },
{ href: '/conversations', icon: MessageSquare, label: 'Conversations' },
{ href: '/channels', icon: Radio, label: 'Channels' },
{ href: '/ai-backends', icon: BotMessageSquare, label: 'AI Backends' },
Expand All @@ -18,18 +15,20 @@ const navItems: { href: string; icon: typeof LayoutDashboard; label: string; exa
{ href: '/settings', icon: Settings, label: 'Settings' },
];

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
export default function DashboardLayout() {
const navigate = useNavigate();
const { pathname } = useLocation();
const [ready, setReady] = useState(false);

useEffect(() => {
if (!isAuthenticated()) {
router.replace('/login');
navigate('/login', { replace: true });
} else {
setReady(true);
const t = getTenant();
if (t) document.title = t.settings?.siteTitle || `${t.name} — ClawScale`;
}
}, [router]);
}, [navigate]);

if (!ready) return null;

Expand All @@ -38,19 +37,19 @@ export default function DashboardLayout({ children }: { children: React.ReactNod

function handleLogout() {
clearAuth();
router.push('/login');
navigate('/login');
}

return (
<div className="flex h-screen bg-gray-50">
<aside className="flex w-60 flex-col bg-navy-900 text-white">
<div className="flex items-center gap-2.5 px-5 py-5 border-b border-white/10">
<Image src="/logo.png" alt="ClawScale" width={28} height={28} className="h-7 w-7" />
<Link to="/" className="flex items-center gap-2.5 px-5 py-5 border-b border-white/10">
<img src="/logo.png" alt="ClawScale" width={28} height={28} className="h-7 w-7" />
<div>
<span className="font-semibold text-white text-base">ClawScale</span>
<p className="text-[10px] text-white/40 leading-none mt-0.5">by Pulse</p>
<p className="text-[10px] text-white/40 leading-none mt-0.5">by ClayPulse</p>
</div>
</div>
</Link>

{tenant && (
<div className="mx-4 mt-4 rounded-lg bg-white/5 px-3 py-2">
Expand All @@ -64,7 +63,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<Link
key={href}
href={href}
to={href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
Expand Down Expand Up @@ -95,7 +94,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</div>
</aside>

<main className="flex-1 overflow-y-auto">{children}</main>
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
);
}
48 changes: 48 additions & 0 deletions packages/app/web/app/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import './globals.css';

import DashboardLayout from './layouts/DashboardLayout';
import Dashboard from './pages/Dashboard';
import Conversations from './pages/Conversations';
import Channels from './pages/Channels';
import AiBackends from './pages/AiBackends';
import Users from './pages/Users';
import EndUsers from './pages/EndUsers';
import Settings from './pages/Settings';
import Onboarding from './pages/Onboarding';
import Login from './pages/Login';
import Register from './pages/Register';
import Onboard from './pages/Onboard';
import { getTenant } from '@/lib/auth';
import type { TenantSettings } from '@clawscale/shared';

function HomeRedirect() {
const tenant = getTenant();
const defaultHomePage = (tenant?.settings as TenantSettings | undefined)?.defaultHomePage ?? '/dashboard';
return <Navigate to={defaultHomePage} replace />;
}

createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/onboard" element={<Onboard />} />
<Route element={<DashboardLayout />}>
<Route index element={<HomeRedirect />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="conversations" element={<Conversations />} />
<Route path="channels" element={<Channels />} />
<Route path="ai-backends" element={<AiBackends />} />
<Route path="end-users" element={<EndUsers />} />
<Route path="users" element={<Users />} />
<Route path="onboarding" element={<Onboarding />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
</StrictMode>,
);
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Plus, Pencil, Trash2, X, Save, BotMessageSquare, Star, StarOff, Lock } from 'lucide-react';
import { api } from '@/lib/api';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Plus, Loader2, Plug, PlugZap, Trash2, Radio, Pencil, Check, X, Copy, Settings } from 'lucide-react';
import { api } from '@/lib/api';
Expand Down Expand Up @@ -32,7 +31,6 @@ export default function Channels() {
const [adding, setAdding] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);

// WhatsApp QR modal
const [qrChannelId, setQrChannelId] = useState<string | null>(null);
const [qrImage, setQrImage] = useState<string | null>(null);
const [qrUrl, setQrUrl] = useState<string | null>(null);
Expand All @@ -47,7 +45,6 @@ export default function Channels() {

useEffect(() => { void load(); }, []);

// Poll QR endpoint while modal is open
useEffect(() => {
if (!qrChannelId) {
if (qrPollRef.current) { clearInterval(qrPollRef.current); qrPollRef.current = null; }
Expand Down Expand Up @@ -118,7 +115,6 @@ export default function Channels() {
if (res.ok) setChannels((prev) => prev.filter((c) => c.id !== id));
}

// Inline rename
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const editRef = useRef<HTMLInputElement>(null);
Expand All @@ -137,7 +133,6 @@ export default function Channels() {
setEditingId(null);
}

// Edit channel modal
const [editChannelId, setEditChannelId] = useState<string | null>(null);
const [editChannelType, setEditChannelType] = useState<ChannelType>('whatsapp');
const [editChannelName, setEditChannelName] = useState('');
Expand Down Expand Up @@ -188,7 +183,7 @@ export default function Channels() {
const schema = CHANNEL_CONFIG_SCHEMA[addType];
const editSchema = CHANNEL_CONFIG_SCHEMA[editChannelType];

const apiBase = process.env['NEXT_PUBLIC_API_URL'] ?? '';
const apiBase = '';

return (
<div className="p-8">
Expand All @@ -200,7 +195,6 @@ export default function Channels() {
{isAdmin && <button className="btn-primary" onClick={() => setShowAdd(true)}><Plus className="h-4 w-4" /> Add channel</button>}
</div>

{/* Add channel modal */}
{showAdd && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div className="card w-full max-w-md p-6">
Expand Down Expand Up @@ -243,7 +237,6 @@ export default function Channels() {
</div>
)}

{/* WhatsApp QR modal */}
{qrChannelId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div className="card w-full max-w-sm p-6 text-center">
Expand Down Expand Up @@ -280,7 +273,6 @@ export default function Channels() {
</div>
)}

{/* Edit channel modal */}
{editChannelId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div className="card w-full max-w-md p-6">
Expand Down Expand Up @@ -398,8 +390,6 @@ export default function Channels() {
);
}

// ── WhatsApp Business webhook setup instructions ─────────────────────────────

function WebhookToggle({ channelId, apiBase }: { channelId: string; apiBase: string }) {
const webhookUrl = `${apiBase}/gateway/whatsapp/${channelId}`;
const [open, setOpen] = useState(false);
Expand Down
Loading
Loading