Feat: Add marketing site
Home page with the live ping demo and architecture diagram, and a dev-only switcher linking to the other portals.
This commit is contained in:
+7
-5
@@ -1,17 +1,19 @@
|
||||
# Multi-stage, one image, both planes -- which one runs is picked at container
|
||||
# start via APP (core|cloud), not at build time. This keeps a single build
|
||||
# cacheable across both instead of rebuilding the whole workspace twice.
|
||||
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /w
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
COPY apps/core/package.json apps/core/package.json
|
||||
COPY apps/cloud/package.json apps/cloud/package.json
|
||||
COPY apps/community/package.json apps/community/package.json
|
||||
COPY apps/community-provider/package.json apps/community-provider/package.json
|
||||
COPY apps/community-admin/package.json apps/community-admin/package.json
|
||||
COPY apps/marketing/package.json apps/marketing/package.json
|
||||
RUN npm install
|
||||
|
||||
FROM deps AS runtime
|
||||
WORKDIR /w
|
||||
COPY . .
|
||||
COPY --from=deps /w/node_modules /w/node_modules
|
||||
EXPOSE 8080 8083
|
||||
CMD sh -c 'npm run build -w @xcloudify/${APP:?set APP=core|cloud} && npm run preview -w @xcloudify/${APP} -- --host 0.0.0.0 --port ${PORT:-8080}'
|
||||
EXPOSE 8080 8081 8082 8083 8084 8086
|
||||
CMD sh -c 'npm run build -w @xcloudify/${APP:?set APP=core|cloud|community|community-provider|community-admin|marketing} && npm run preview -w @xcloudify/${APP} -- --host 0.0.0.0 --port ${PORT:-8080}'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CLOUD_URL=http://localhost:8080
|
||||
COMMUNITY_URL=http://localhost:8081
|
||||
CORE_URL=http://localhost:8083
|
||||
COMMUNITY_API_BASE_URL=http://localhost:5002/api
|
||||
# true|false to force the floating dev portal switcher on/off; unset follows
|
||||
# Vite's DEV flag (on for `vite dev`, off for a production build/preview).
|
||||
SHOW_DEV_NAV=
|
||||
|
||||
ALLOWED_HOST=localhost
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
services:
|
||||
marketing:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
container_name: xcloudify-marketing
|
||||
environment:
|
||||
APP: marketing
|
||||
PORT: "8082"
|
||||
ALLOWED_HOST: ${ALLOWED_HOST:-localhost}
|
||||
VITE_CLOUD_URL: ${CLOUD_URL:-}
|
||||
VITE_COMMUNITY_URL: ${COMMUNITY_URL:-}
|
||||
VITE_CORE_URL: ${CORE_URL:-}
|
||||
VITE_COMMUNITY_API_BASE_URL: ${COMMUNITY_API_BASE_URL:-http://localhost:5002/api}
|
||||
# Shows the floating dev portal switcher regardless of build mode --
|
||||
# unset falls back to Vite's DEV flag (see DevPortalSwitcher.tsx).
|
||||
VITE_SHOW_DEV_NAV: ${SHOW_DEV_NAV:-}
|
||||
ports:
|
||||
- "8082:8082"
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>xcloudify</title>
|
||||
<meta name="description" content="Core, public cloud, and community cloud on one platform" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@xcloudify/marketing",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": { "@xcloudify/shared": "*" }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Public marketing site. No auth, no session, no sidebar -- it is a separate
|
||||
* deployable from the portals precisely so it can be served publicly without
|
||||
* carrying any authenticated surface.
|
||||
*
|
||||
* The one live thing it does is the latency demo, which calls the community
|
||||
* plane's anonymous endpoints directly (nodes/available + ping-session) and
|
||||
* then measures browser-to-worker RTT over a real WebSocket. That is real
|
||||
* infrastructure, not a mock.
|
||||
*/
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { HomePage } from "@/components/HomePage";
|
||||
import { DevPortalSwitcher } from "./DevPortalSwitcher";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<BrowserRouter>
|
||||
<DevPortalSwitcher />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Dev-only quick nav. The public page has no portal-chooser section --
|
||||
* every "sign in" CTA on HomePage links straight to cloud (see goToCloud
|
||||
* there), and core has no public login at all. During local/staging work
|
||||
* this is the only on-page way to reach core or community without typing
|
||||
* the URL by hand.
|
||||
*
|
||||
* Visibility is VITE_SHOW_DEV_NAV (set via SHOW_DEV_NAV in .env / compose),
|
||||
* not just Vite's built-in DEV flag -- the Docker path always runs `vite
|
||||
* build` + `vite preview`, a production build, where DEV is false even on a
|
||||
* box you're using for local testing. Leave the env var unset and it still
|
||||
* falls back to DEV, so a plain `vite dev` shows it with zero config; set it
|
||||
* explicitly to force the button on (or off) regardless of build mode.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Boxes, Cloud as CloudIcon, Users, Wrench } from "lucide-react";
|
||||
|
||||
type DevTarget = { label: string; href?: string; icon: React.ComponentType<any> };
|
||||
|
||||
const DEV_TARGETS: DevTarget[] = [
|
||||
{ label: "Core — operator console", href: import.meta.env.VITE_CORE_URL, icon: Boxes },
|
||||
{ label: "Cloud portal", href: import.meta.env.VITE_CLOUD_URL, icon: CloudIcon },
|
||||
{ label: "Community portal", href: import.meta.env.VITE_COMMUNITY_URL, icon: Users },
|
||||
];
|
||||
|
||||
const explicitShowDevNav = import.meta.env.VITE_SHOW_DEV_NAV;
|
||||
const SHOW_DEV_NAV =
|
||||
explicitShowDevNav != null ? explicitShowDevNav === "true" : import.meta.env.DEV;
|
||||
|
||||
export function DevPortalSwitcher() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!SHOW_DEV_NAV) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 top-4 z-50">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className="group relative flex items-center gap-1.5 rounded-full border border-amber-400/60 bg-amber-400/10 px-3 py-1.5 text-xs font-medium text-amber-700 shadow-sm outline-none backdrop-blur transition-colors hover:bg-amber-400/20 dark:text-amber-300"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
{!open && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75" />
|
||||
)}
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-amber-500" />
|
||||
</span>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Dev
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Jump to a running portal
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{DEV_TARGETS.map(({ label, href, icon: Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={label}
|
||||
disabled={!href}
|
||||
onClick={() => href && (window.location.href = href)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{!href && <span className="text-[10px] text-muted-foreground">not set</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "@/index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,5 @@
|
||||
// Re-exports the shared theme so all four apps look identical. Content globs
|
||||
// are relative to this file, so each app scans its own src plus the shared
|
||||
// component library.
|
||||
import shared from "../../packages/shared/tailwind.config";
|
||||
export default shared;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020", "useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext",
|
||||
"skipLibCheck": true, "moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true, "isolatedModules": true,
|
||||
"moduleDetection": "force", "noEmit": true, "jsx": "react-jsx",
|
||||
"strict": false, "noUnusedLocals": false, "noUnusedParameters": false,
|
||||
"noImplicitAny": false, "strictNullChecks": false, "allowJs": true,
|
||||
"baseUrl": ".", "paths": { "@/*": ["../../packages/shared/src/*"] }
|
||||
},
|
||||
"include": ["src", "../../packages/shared/src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import path from "path";
|
||||
|
||||
// Every app resolves "@/" to the SHARED package, not its own src. That is
|
||||
// what lets ~110 shared components keep their existing imports untouched
|
||||
// while each app stays a thin entry point that only picks which portals to
|
||||
// mount. App-local files import relatively.
|
||||
const PORT = parseInt(process.env.PORT ?? "8082", 10);
|
||||
|
||||
export default defineConfig({
|
||||
server: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") },
|
||||
preview: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") },
|
||||
plugins: [react()],
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "../../packages/shared/src") } },
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Shield, Zap, Globe, Users, ArrowRight, Check, Boxes,
|
||||
Cloud as CloudIcon, Server, Network, Database, ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { CoreArchitectureDiagram } from "@/components/marketing/CoreArchitectureDiagram";
|
||||
import { LivePingDemo } from "@/components/marketing/LivePingDemo";
|
||||
|
||||
type PlatformTab = "core" | "cloud" | "community";
|
||||
|
||||
const PLATFORM_MODES: { value: PlatformTab; icon: typeof Boxes; label: string }[] = [
|
||||
{ value: "core", icon: Boxes, label: "Core" },
|
||||
{ value: "cloud", icon: CloudIcon, label: "Cloud" },
|
||||
{ value: "community", icon: Users, label: "Community" },
|
||||
];
|
||||
|
||||
const HERO_STACK_ROWS = [
|
||||
{ icon: Server, label: "Compute" },
|
||||
{ icon: Network, label: "Network" },
|
||||
{ icon: Database, label: "Storage" },
|
||||
{ icon: Boxes, label: "Nodes" },
|
||||
];
|
||||
|
||||
const HERO_MODE_BARS = [
|
||||
{ label: "Core", pct: 70 },
|
||||
{ label: "Cloud", pct: 45 },
|
||||
{ label: "Community", pct: 85 },
|
||||
];
|
||||
|
||||
const CORE_POINTS = [
|
||||
"One primitive for compute, network, and storage",
|
||||
"Runs underneath any orchestrator you already use",
|
||||
"Fewer nodes for the same headroom",
|
||||
];
|
||||
|
||||
const CLOUD_FEATURES = [
|
||||
{ icon: Shield, title: "OIDC login", desc: "Single sign-on, no shared credentials to leak." },
|
||||
{ icon: Globe, title: "Projects & VDCs", desc: "Organize resources the way your org is structured." },
|
||||
{ icon: Users, title: "Role-based access", desc: "Invite teammates as readers or writers, per VDC." },
|
||||
];
|
||||
|
||||
const COMMUNITY_POINTS = [
|
||||
"Real machines, not a mockup",
|
||||
"Latency measured directly browser → worker",
|
||||
"No signup required to try it",
|
||||
];
|
||||
|
||||
const CAPABILITIES = [
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Enterprise Security",
|
||||
description: "Military-grade security with end-to-end encryption and compliance"
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Lightning Fast",
|
||||
description: "Deploy and scale workloads instantly across global infrastructure"
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: "Global Reach",
|
||||
description: "Multi-region deployment with edge computing capabilities"
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Team Collaboration",
|
||||
description: "Built for teams with role-based access and real-time collaboration"
|
||||
}
|
||||
];
|
||||
|
||||
const BENEFITS = [
|
||||
"99.99% uptime SLA",
|
||||
"Auto-scaling infrastructure",
|
||||
"24/7 expert support",
|
||||
"Enterprise-grade security",
|
||||
"Global CDN network",
|
||||
"One-click deployments"
|
||||
];
|
||||
|
||||
function SectionHeading({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="mb-14 max-w-2xl space-y-4">
|
||||
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
|
||||
<p className="text-muted-foreground text-lg">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
// The marketing site hosts no authenticated surface; every "sign in" CTA
|
||||
// is a real link out to wherever the cloud portal is actually deployed --
|
||||
// that's the plane customers sign up on day to day (see the Cloud tab
|
||||
// below). A no-op with no configured URL is fine here: on any real
|
||||
// deploy this is set, and locally the dev switcher covers the gap.
|
||||
const cloudUrl = import.meta.env.VITE_CLOUD_URL as string | undefined;
|
||||
const goToCloud = () => {
|
||||
if (cloudUrl) window.location.href = cloudUrl;
|
||||
};
|
||||
const [platformTab, setPlatformTab] = useState<PlatformTab>("core");
|
||||
|
||||
const goToPlatform = (tab: PlatformTab) => {
|
||||
setPlatformTab(tab);
|
||||
document.getElementById("platform")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative bg-gradient-to-br from-background via-background to-muted/30">
|
||||
{/* Background texture */}
|
||||
<div className="pointer-events-none absolute inset-0 z-0 bg-grid-soft"></div>
|
||||
<div className="relative z-10">
|
||||
{/* Navigation */}
|
||||
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex h-16 items-center">
|
||||
<div className="flex items-center gap-10">
|
||||
<div className="text-2xl font-bold text-primary">XCloudify</div>
|
||||
<div className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1 outline-none hover:text-foreground transition-colors">
|
||||
Platform <ChevronDown className="h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-48"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{PLATFORM_MODES.map((mode) => (
|
||||
<DropdownMenuItem key={mode.value} onClick={() => goToPlatform(mode.value)} className="gap-2">
|
||||
<mode.icon className="h-4 w-4 text-primary" /> {mode.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<a href="#capabilities" className="hover:text-foreground transition-colors">Capabilities</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<Button onClick={() => goToCloud()} className="gap-2">
|
||||
Login <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="container mx-auto px-6 pt-20 pb-28">
|
||||
<div className="grid gap-16 lg:grid-cols-12 lg:items-center">
|
||||
<div className="lg:col-span-6 space-y-8">
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<Boxes className="h-3.5 w-3.5" /> Core · Cloud · Community
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-4xl md:text-6xl font-bold tracking-tight leading-[1.08]">
|
||||
One infrastructure platform.
|
||||
<span className="block text-primary">Run it your way.</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-muted-foreground max-w-xl leading-relaxed">
|
||||
The same compute, network, and storage primitives — self-hosted as
|
||||
core, consumed managed as cloud, or leased from community hardware.
|
||||
Pick the model that fits, switch anytime.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button size="lg" onClick={() => goToCloud()} className="gap-2">
|
||||
Get Started <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={() => goToPlatform("core")}>
|
||||
See how it works
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
{PLATFORM_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => goToPlatform(mode.value)}
|
||||
className="flex items-center gap-2 rounded-full border bg-background/70 px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<mode.icon className="h-4 w-4" /> {mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-6">
|
||||
<div className="relative">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -inset-6 -z-10 rounded-[2rem] bg-gradient-to-br from-primary/15 via-primary/5 to-transparent blur-2xl"
|
||||
/>
|
||||
<Card className="overflow-hidden border-muted/60 shadow-2xl">
|
||||
<div className="flex items-center gap-1.5 border-b bg-muted/40 px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-400/60" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-400/60" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-green-400/60" />
|
||||
<span className="ml-3 text-xs text-muted-foreground">console.xcloudify</span>
|
||||
</div>
|
||||
<CardContent className="grid grid-cols-5 gap-5 p-6">
|
||||
<div className="col-span-2 space-y-2.5">
|
||||
{HERO_STACK_ROWS.map((row) => (
|
||||
<div key={row.label} className="flex items-center gap-2 rounded-md bg-muted/40 px-3 py-2.5">
|
||||
<row.icon className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
<span className="text-xs font-medium">{row.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-3 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{HERO_MODE_BARS.map((m) => (
|
||||
<div key={m.label} className="space-y-2 rounded-md border bg-background p-3">
|
||||
<div className="text-[11px] text-muted-foreground">{m.label}</div>
|
||||
<div className="h-10 overflow-hidden rounded bg-primary/15">
|
||||
<div
|
||||
className="w-full rounded bg-primary/60"
|
||||
style={{ height: `${m.pct}%`, marginTop: `${100 - m.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-md border px-3 py-2.5">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500" />
|
||||
<div className="h-2 flex-1 rounded bg-muted" />
|
||||
<div className="h-2 w-10 rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Platform: three modalities */}
|
||||
<section id="platform" className="border-t bg-muted/10 scroll-mt-16">
|
||||
<div className="container mx-auto px-6 py-24">
|
||||
<SectionHeading
|
||||
title="Three ways to run it"
|
||||
description="The same infrastructure primitives, exposed however your team needs to consume them."
|
||||
/>
|
||||
|
||||
<Tabs value={platformTab} onValueChange={(v) => setPlatformTab(v as PlatformTab)}>
|
||||
<TabsList className="mb-12">
|
||||
{PLATFORM_MODES.map((mode) => (
|
||||
<TabsTrigger key={mode.value} value={mode.value} className="gap-1.5">
|
||||
<mode.icon className="h-4 w-4" /> {mode.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="core">
|
||||
<div className="grid gap-10 lg:grid-cols-12 lg:items-start">
|
||||
<div className="lg:col-span-4 space-y-5 lg:sticky lg:top-24">
|
||||
<h3 className="text-xl font-semibold">Sits under your own cloud stack</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Core is identity-free infrastructure — compute, network, and
|
||||
storage as one set of primitives underneath whatever orchestrator
|
||||
you already run.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{CORE_POINTS.map((point) => (
|
||||
<li key={point} className="flex gap-2.5 text-sm">
|
||||
<Check className="h-4 w-4 shrink-0 text-primary mt-0.5" />
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="lg:col-span-8">
|
||||
<Card className="border-muted/50">
|
||||
<CardContent className="p-6 md:p-8">
|
||||
<CoreArchitectureDiagram />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cloud">
|
||||
<div className="grid gap-10 lg:grid-cols-12 lg:items-start">
|
||||
<div className="lg:col-span-5 space-y-5 lg:sticky lg:top-24">
|
||||
<h3 className="text-xl font-semibold">The managed control plane for teams</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Cloud is what most customers use day to day: sign in, get a
|
||||
project provisioned automatically, organize work into virtual
|
||||
data centers, and invite teammates with read or write roles
|
||||
scoped per VDC.
|
||||
</p>
|
||||
<Button onClick={() => goToCloud()} className="gap-2">
|
||||
Try the cloud console <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="lg:col-span-7 space-y-4">
|
||||
{CLOUD_FEATURES.map((f) => (
|
||||
<div key={f.title} className="flex items-start gap-4 rounded-lg border bg-background p-5">
|
||||
<div className="w-10 h-10 shrink-0 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<f.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold">{f.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{f.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="community">
|
||||
<div className="grid gap-10 lg:grid-cols-12 lg:items-start">
|
||||
<div className="lg:col-span-4 space-y-5 lg:sticky lg:top-24">
|
||||
<h3 className="text-xl font-semibold">Leased hardware, real machines</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Community is spare capacity from third-party operators: register
|
||||
your own machine to earn from it, or lease someone else's after
|
||||
checking the latency yourself.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{COMMUNITY_POINTS.map((point) => (
|
||||
<li key={point} className="flex gap-2.5 text-sm">
|
||||
<Check className="h-4 w-4 shrink-0 text-primary mt-0.5" />
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="lg:col-span-8">
|
||||
<LivePingDemo />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Capabilities Grid */}
|
||||
<section id="capabilities" className="container mx-auto px-6 py-24 scroll-mt-16">
|
||||
<SectionHeading
|
||||
title="Why choose xCloudify?"
|
||||
description="Everything you need to build, deploy, and scale modern applications."
|
||||
/>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{CAPABILITIES.map((feature) => (
|
||||
<Card key={feature.title} className="border-muted/50 hover:border-primary/20 transition-colors">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="w-11 h-11 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<feature.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">{feature.title}</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section className="border-t bg-muted/10">
|
||||
<div className="container mx-auto px-6 py-24">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-3xl md:text-4xl font-bold">
|
||||
Built for Modern Teams
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg leading-relaxed">
|
||||
From startups to Fortune 500 companies, XCloudify provides the tools
|
||||
and infrastructure you need to succeed in the cloud-first world.
|
||||
</p>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{BENEFITS.map((benefit) => (
|
||||
<div key={benefit} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 bg-green-500/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm">{benefit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button size="lg" onClick={() => goToCloud()} className="gap-2">
|
||||
Start Your Journey <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Card className="p-8 bg-gradient-to-br from-primary/5 to-primary/10 border-primary/20">
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold text-primary">99.99%</div>
|
||||
<div className="text-sm text-muted-foreground">Uptime SLA</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold text-primary">< 100ms</div>
|
||||
<div className="text-sm text-muted-foreground">Global Latency</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold text-primary">24/7</div>
|
||||
<div className="text-sm text-muted-foreground">Expert Support</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="border-t bg-muted/20">
|
||||
<div className="container mx-auto px-6 py-24 text-center">
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
<h2 className="text-3xl md:text-4xl font-bold">
|
||||
Ready to Get Started?
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Join thousands of developers already building on XCloudify
|
||||
</p>
|
||||
<Button size="lg" onClick={() => goToCloud()} className="gap-2">
|
||||
Access Your Dashboard <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t bg-background">
|
||||
<div className="container mx-auto px-6 py-16">
|
||||
<div className="grid gap-12 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-3 lg:col-span-2">
|
||||
<div className="text-xl font-bold text-primary">XCloudify</div>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
One infrastructure platform — run it yourself as core, consume it
|
||||
managed as cloud, or lease community hardware.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-semibold">Platform</div>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li><a href="#platform" className="hover:text-foreground transition-colors">Core</a></li>
|
||||
<li><a href="#platform" className="hover:text-foreground transition-colors">Cloud</a></li>
|
||||
<li><a href="#platform" className="hover:text-foreground transition-colors">Community</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-semibold">Get started</div>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<button onClick={() => goToCloud()} className="hover:text-foreground transition-colors">
|
||||
Sign in
|
||||
</button>
|
||||
</li>
|
||||
<li><a href="#capabilities" className="hover:text-foreground transition-colors">Capabilities</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-4 border-t pt-8 text-xs text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} XCloudify. All rights reserved.</span>
|
||||
<span>Community hardware is operator-owned — see the marketplace for details.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Boxes, Database, Network, Server } from "lucide-react";
|
||||
|
||||
const SELF_MANAGED_LAYERS = [
|
||||
"Your applications",
|
||||
"Load balancer",
|
||||
"Orchestrator (Kubernetes)",
|
||||
"Storage layer — self-managed",
|
||||
"Network / SDN — self-managed",
|
||||
];
|
||||
|
||||
const CORE_LAYERS = ["Your applications", "Orchestrator (Kubernetes, or bring your own)"];
|
||||
|
||||
/**
|
||||
* Visualizes why xCloudify core exists: it collapses the storage/network
|
||||
* layers teams otherwise hand-roll into one primitive underneath whatever
|
||||
* orchestrator they already run, so the same headroom needs fewer nodes.
|
||||
*/
|
||||
export function CoreArchitectureDiagram() {
|
||||
return (
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="text-sm font-medium text-muted-foreground">Building it yourself</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{SELF_MANAGED_LAYERS.map((label) => (
|
||||
<div key={label} className="rounded-md border bg-muted/40 px-4 py-3 text-sm">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto pt-6">
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square rounded bg-muted-foreground/20" />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-center text-xs text-muted-foreground">
|
||||
12 nodes, each running a slice of every layer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="text-sm font-medium text-primary">With xCloudify Core</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{CORE_LAYERS.map((label) => (
|
||||
<div key={label} className="rounded-md border bg-muted/40 px-4 py-3 text-sm">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
<div className="relative overflow-hidden rounded-md border-2 border-primary/40 bg-gradient-to-br from-primary/10 to-primary/5 px-4 py-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-1/2 top-0 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-primary animate-flow-down"
|
||||
/>
|
||||
<div className="flex items-center gap-2 font-medium text-primary">
|
||||
<Boxes className="h-4 w-4" />
|
||||
xCloudify Core
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="h-3 w-3" /> compute
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Network className="h-3 w-3" /> network
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Database className="h-3 w-3" /> storage
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto pt-6">
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square rounded bg-primary/30 ring-1 ring-primary/50" />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-center text-xs font-medium text-primary">
|
||||
4 nodes, same headroom
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Cpu, Gauge, MapPin, MemoryStick, ShieldCheck, Wifi, WifiOff } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { communityApi, CommunityNode, measurePing } from "@/services/communityApi";
|
||||
|
||||
interface PingResult {
|
||||
medianMs: number | null;
|
||||
path: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative browser<->worker link. Dashed and static when there is no
|
||||
* community plane to reach; pulsing when there is, so the demo's own visual
|
||||
* matches what it is honestly able to claim in the copy above it.
|
||||
*/
|
||||
function ConnectionIllustration({ reachable }: { reachable: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 220 90" className="h-24 w-full max-w-xs text-muted-foreground/50" aria-hidden="true">
|
||||
<rect x="6" y="27" width="56" height="38" rx="7" className="fill-background stroke-current" strokeWidth="1.5" />
|
||||
<circle cx="17" cy="36" r="2" className="fill-current" />
|
||||
<circle cx="24" cy="36" r="2" className="fill-current" />
|
||||
<circle cx="31" cy="36" r="2" className="fill-current" />
|
||||
<rect x="14" y="44" width="40" height="4" rx="1" className="fill-current opacity-30" />
|
||||
<rect x="14" y="52" width="28" height="4" rx="1" className="fill-current opacity-30" />
|
||||
|
||||
<line
|
||||
x1="62" y1="46" x2="158" y2="46"
|
||||
strokeDasharray="5 5"
|
||||
className={reachable ? "stroke-primary" : "stroke-current"}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{reachable ? (
|
||||
<circle cx="110" cy="46" r="3" className="fill-primary">
|
||||
<animate attributeName="opacity" values="1;0.15;1" dur="1.4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
) : (
|
||||
<circle cx="110" cy="46" r="3" className="fill-destructive" />
|
||||
)}
|
||||
|
||||
<rect x="158" y="18" width="56" height="54" rx="7" className="fill-background stroke-current" strokeWidth="1.5" />
|
||||
<rect x="166" y="28" width="40" height="6" rx="1" className="fill-current opacity-30" />
|
||||
<rect x="166" y="40" width="40" height="6" rx="1" className="fill-current opacity-30" />
|
||||
<rect x="166" y="52" width="40" height="6" rx="1" className="fill-current opacity-30" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LivePingDemo() {
|
||||
const [nodes, setNodes] = useState<CommunityNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [offline, setOffline] = useState(false);
|
||||
const [pings, setPings] = useState<Record<string, PingResult | "pending">>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setNodes(await communityApi.listAvailableNodes());
|
||||
setOffline(false);
|
||||
} catch (err) {
|
||||
// A network-level failure just means this install isn't running a
|
||||
// community plane -- expected on plenty of deploys. Fold it into the
|
||||
// offline illustration below instead of surfacing the raw fetch
|
||||
// error, but keep it in the console for anyone actually debugging.
|
||||
console.error("LivePingDemo: could not reach the community plane", err);
|
||||
setOffline(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const runPing = async (node: CommunityNode) => {
|
||||
setPings((p) => ({ ...p, [node.id]: "pending" }));
|
||||
try {
|
||||
const session = await communityApi.createPingSession(node.id);
|
||||
const result = await measurePing(session);
|
||||
setPings((p) => ({
|
||||
...p,
|
||||
[node.id]: { medianMs: result.medianMs, path: result.path },
|
||||
}));
|
||||
} catch (err) {
|
||||
setPings((p) => ({
|
||||
...p,
|
||||
[node.id]: {
|
||||
medianMs: null,
|
||||
path: "unknown",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3 rounded-md border bg-muted/30 p-3 text-sm">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<p className="text-muted-foreground">
|
||||
{offline
|
||||
? "This demo pings real community-hosted machines directly from your browser, not through our servers — it just needs a community plane to talk to, and this install isn't running one."
|
||||
: "These are real, community-hosted machines on the marketplace right now. Ping one straight from your browser — no signup required. We only introduce the two ends; the measurement runs over a direct peer-to-peer connection to the machine, not through our servers."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Loading live nodes…</p>}
|
||||
|
||||
{!loading && offline && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-10 text-center">
|
||||
<ConnectionIllustration reachable={false} />
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<WifiOff className="h-4 w-4 text-muted-foreground" />
|
||||
Demo unavailable on this install
|
||||
</div>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
No community plane is configured here, so there's nothing to ping yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !offline && nodes.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-10 text-center">
|
||||
<ConnectionIllustration reachable />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No community nodes are online right now — check back shortly.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{!offline && nodes.slice(0, 6).map((node) => {
|
||||
const ping = pings[node.id];
|
||||
return (
|
||||
<Card key={node.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{node.name}</CardTitle>
|
||||
<Badge variant="outline">{node.trust_tier}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
{node.specs.cpu_cores ?? "?"} cores
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MemoryStick className="h-3 w-3" />
|
||||
{Math.round((node.specs.memory_mb ?? 0) / 1024)} GB
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{node.city ? `${node.city}, ${node.country}` : node.country}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t pt-3 text-sm">
|
||||
<Gauge className="h-4 w-4 text-muted-foreground" />
|
||||
{ping === "pending" && <span className="text-muted-foreground">Measuring…</span>}
|
||||
{ping && ping !== "pending" && ping.medianMs !== null && (
|
||||
<span className="font-medium">
|
||||
{ping.medianMs.toFixed(0)} ms
|
||||
<span className="font-normal text-muted-foreground"> via {ping.path}</span>
|
||||
</span>
|
||||
)}
|
||||
{ping && ping !== "pending" && ping.error && (
|
||||
<span className="text-xs text-destructive">{ping.error}</span>
|
||||
)}
|
||||
{!ping && (
|
||||
<span className="text-muted-foreground">
|
||||
{node.pingable === false ? "Worker offline" : "Not measured yet"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={() => runPing(node)}
|
||||
disabled={ping === "pending" || node.pingable === false}
|
||||
>
|
||||
<Wifi className="h-4 w-4" />
|
||||
{ping === "pending"
|
||||
? "Measuring…"
|
||||
: node.pingable === false
|
||||
? "Unreachable right now"
|
||||
: "Ping"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user