VIBN Frontend for Coolify deployment

This commit is contained in:
2026-02-15 19:25:52 -08:00
commit 40bf8428cd
398 changed files with 76513 additions and 0 deletions

View File

@@ -0,0 +1,546 @@
"use client";
import { use, useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
GitBranch,
ChevronRight,
Search,
Lightbulb,
ShoppingCart,
UserPlus,
Rocket,
Zap,
HelpCircle,
CreditCard,
Loader2,
CheckCircle2,
Circle,
X,
Palette,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
interface WorkItem {
id: string;
title: string;
path: string;
status: "built" | "in_progress" | "missing";
category: string;
sessionsCount: number;
commitsCount: number;
journeyStage?: string;
}
interface AssetNode {
id: string;
name: string;
asset_type: string;
must_have_for_v1: boolean;
asset_metadata: {
why_it_exists: string;
which_user_it_serves?: string;
problem_it_helps_with?: string;
connection_to_magic_moment: string;
journey_stage?: string;
visual_style_notes?: string;
implementation_notes?: string;
};
children?: AssetNode[];
}
interface JourneyStage {
id: string;
name: string;
icon: any;
description: string;
color: string;
items: WorkItem[];
assets: AssetNode[]; // Visual assets for this stage
}
export default function JourneyPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = use(params);
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [touchpointAssets, setTouchpointAssets] = useState<AssetNode[]>([]);
const [loading, setLoading] = useState(true);
const [selectedStage, setSelectedStage] = useState<string | null>(null);
const [journeyStages, setJourneyStages] = useState<JourneyStage[]>([]);
useEffect(() => {
loadJourneyData();
}, [projectId]);
const loadJourneyData = async () => {
try {
setLoading(true);
// Load work items for stats
const timelineResponse = await fetch(`/api/projects/${projectId}/timeline-view`);
if (timelineResponse.ok) {
const timelineData = await timelineResponse.json();
setWorkItems(timelineData.workItems);
}
// Load AI-generated touchpoints tree
const mvpResponse = await fetch(`/api/projects/${projectId}/mvp-checklist`);
if (mvpResponse.ok) {
const mvpData = await mvpResponse.json();
// Extract touchpoints from AI response if it exists
if (mvpData.aiGenerated && mvpData.touchpointsTree) {
const allTouchpoints = flattenAssetNodes(mvpData.touchpointsTree.nodes || []);
setTouchpointAssets(allTouchpoints);
}
}
// Build journey stages from both work items and touchpoint assets
const stages = buildJourneyStages(workItems, touchpointAssets);
setJourneyStages(stages);
} catch (error) {
console.error("Error loading journey data:", error);
toast.error("Failed to load journey data");
} finally {
setLoading(false);
}
};
// Flatten nested asset nodes
const flattenAssetNodes = (nodes: AssetNode[]): AssetNode[] => {
const flattened: AssetNode[] = [];
const flatten = (node: AssetNode) => {
flattened.push(node);
if (node.children && node.children.length > 0) {
node.children.forEach(child => flatten(child));
}
};
nodes.forEach(node => flatten(node));
return flattened;
};
const getJourneySection = (item: WorkItem): string => {
const title = item.title.toLowerCase();
const path = item.path.toLowerCase();
// Discovery
if (path === '/' || title.includes('landing') || title.includes('marketing page')) return 'Discovery';
if (item.category === 'Social' && !path.includes('settings')) return 'Discovery';
// Research
if (item.category === 'Content' || path.includes('/docs')) return 'Research';
if (title.includes('marketing dashboard')) return 'Research';
// Onboarding
if (path.includes('auth') || path.includes('oauth')) return 'Onboarding';
if (path.includes('signup') || path.includes('signin')) return 'Onboarding';
// First Use
if (title.includes('onboarding')) return 'First Use';
if (title.includes('getting started')) return 'First Use';
if (path.includes('workspace') && !path.includes('settings')) return 'First Use';
if (title.includes('creation flow')) return 'First Use';
// Active
if (path.includes('overview') || path.includes('/dashboard')) return 'Active';
if (path.includes('timeline-plan') || path.includes('audit') || path.includes('mission')) return 'Active';
if (path.includes('/api/projects') || path.includes('mvp-checklist')) return 'Active';
// Support
if (path.includes('settings')) return 'Support';
// Purchase
if (path.includes('billing') || path.includes('payment')) return 'Purchase';
return 'Active';
};
const buildJourneyStages = (items: WorkItem[], assets: AssetNode[]): JourneyStage[] => {
const stageDefinitions = [
{
id: "discovery",
name: "Discovery",
stageMappings: ["Awareness", "Discovery"],
icon: Search,
description: "Found you online via social, blog, or ad",
color: "bg-blue-100 border-blue-300 text-blue-900",
},
{
id: "research",
name: "Research",
stageMappings: ["Curiosity", "Research"],
icon: Lightbulb,
description: "Checking out features, pricing, and value",
color: "bg-purple-100 border-purple-300 text-purple-900",
},
{
id: "onboarding",
name: "Onboarding",
stageMappings: ["First Try", "Onboarding"],
icon: UserPlus,
description: "Creating account to try the product",
color: "bg-green-100 border-green-300 text-green-900",
},
{
id: "first-use",
name: "First Use",
stageMappings: ["First Real Day", "First Use"],
icon: Rocket,
description: "Zero to experiencing the magic",
color: "bg-orange-100 border-orange-300 text-orange-900",
},
{
id: "active",
name: "Active",
stageMappings: ["Habit", "Active", "Post-MVP"],
icon: Zap,
description: "Using the magic repeatedly",
color: "bg-yellow-100 border-yellow-300 text-yellow-900",
},
{
id: "support",
name: "Support",
stageMappings: ["Support"],
icon: HelpCircle,
description: "Getting help to maximize value",
color: "bg-indigo-100 border-indigo-300 text-indigo-900",
},
{
id: "purchase",
name: "Purchase",
stageMappings: ["Decision to Pay", "Purchase"],
icon: CreditCard,
description: "Time to pay to keep using",
color: "bg-pink-100 border-pink-300 text-pink-900",
},
];
return stageDefinitions.map(stage => {
// Get work items for this stage
const stageItems = items.filter(item => {
const section = getJourneySection(item);
return section === stage.name;
});
// Get touchpoint assets for this stage from AI-generated metadata
const stageAssets = assets.filter(asset => {
const assetJourneyStage = asset.asset_metadata?.journey_stage || '';
return stage.stageMappings.some(mapping =>
assetJourneyStage.toLowerCase().includes(mapping.toLowerCase())
);
});
return {
...stage,
items: stageItems,
assets: stageAssets,
};
});
};
const getStatusIcon = (status: string) => {
if (status === "built") return <CheckCircle2 className="h-3 w-3 text-green-600" />;
if (status === "in_progress") return <Circle className="h-3 w-3 text-blue-600 fill-blue-600" />;
return <Circle className="h-3 w-3 text-gray-400" />;
};
const selectedStageData = journeyStages.find(s => s.id === selectedStage);
return (
<div className="relative h-full w-full bg-background overflow-hidden flex">
{/* Left Sidebar */}
<CollapsibleSidebar>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2">Journey Stats</h3>
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Stages</span>
<span className="font-medium">{journeyStages.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total Assets</span>
<span className="font-medium">{journeyStages.reduce((sum, stage) => sum + stage.assets.length, 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Work Items</span>
<span className="font-medium">{workItems.length}</span>
</div>
</div>
</div>
</div>
</CollapsibleSidebar>
{/* Main Content */}
<div className="flex-1 flex flex-col bg-background overflow-hidden">
{/* Header */}
<div className="border-b p-4">
<div className="flex items-center gap-4">
<GitBranch className="h-6 w-6" />
<div>
<h1 className="text-xl font-bold">Customer Journey</h1>
<p className="text-sm text-muted-foreground">
Track touchpoints across the customer lifecycle
</p>
</div>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center flex-1">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 overflow-auto">
{/* Journey Flow */}
<div className="p-8">
<div className="flex items-center gap-0 overflow-x-auto pb-4">
{journeyStages.map((stage, index) => (
<div key={stage.id} className="flex items-center flex-shrink-0">
{/* Stage Card */}
<Card
className={`w-64 border-2 cursor-pointer transition-all hover:shadow-lg ${
stage.color
} ${selectedStage === stage.id ? "ring-2 ring-primary" : ""}`}
onClick={() => setSelectedStage(stage.id)}
>
<div className="p-4 space-y-3">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<stage.icon className="h-5 w-5" />
<h3 className="font-bold text-sm">{stage.name}</h3>
</div>
<Badge variant="secondary" className="text-xs">
{stage.items.length}
</Badge>
</div>
{/* Description */}
<p className="text-xs opacity-80 line-clamp-2">{stage.description}</p>
{/* Stats */}
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
<span>
{stage.items.filter(i => i.status === "built").length} built
</span>
</div>
<div className="flex items-center gap-1">
<Circle className="h-3 w-3 fill-current" />
<span>
{stage.items.filter(i => i.status === "in_progress").length} in progress
</span>
</div>
</div>
{/* Progress Bar */}
<div className="w-full bg-white/50 rounded-full h-1.5">
<div
className="bg-current h-1.5 rounded-full transition-all"
style={{
width: `${
stage.items.length > 0
? (stage.items.filter(i => i.status === "built").length /
stage.items.length) *
100
: 0
}%`,
}}
/>
</div>
</div>
</Card>
{/* Connector Arrow */}
{index < journeyStages.length - 1 && (
<ChevronRight className="h-8 w-8 text-muted-foreground mx-2 flex-shrink-0" />
)}
</div>
))}
</div>
</div>
{/* Stage Details Panel */}
{selectedStageData && (
<div className="border-t bg-muted/30 p-6">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<selectedStageData.icon className="h-6 w-6" />
<div>
<h2 className="text-lg font-bold">{selectedStageData.name} Touchpoints</h2>
<p className="text-sm text-muted-foreground">
{selectedStageData.description}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedStage(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
{selectedStageData.assets.length === 0 && selectedStageData.items.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-muted-foreground">
No assets defined for this stage yet
</p>
<Button className="mt-4" onClick={() => toast.info("AI will generate assets when you regenerate the plan")}>
Generate with AI
</Button>
</Card>
) : (
<div className="space-y-6">
{/* AI-Generated Visual Assets */}
{selectedStageData.assets.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide">
Visual Assets
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{selectedStageData.assets.map((asset) => (
<Card key={asset.id} className="overflow-hidden hover:shadow-lg transition-all group cursor-pointer">
{/* Visual Preview */}
<div className="aspect-video bg-gradient-to-br from-indigo-50 to-purple-50 relative overflow-hidden border-b">
{/* Placeholder for actual design preview */}
<div className="absolute inset-0 flex items-center justify-center p-6">
<div className="text-center">
<Palette className="h-10 w-10 text-indigo-400 mx-auto mb-2" />
<p className="text-xs text-indigo-600 font-medium line-clamp-2">
{asset.name}
</p>
</div>
</div>
{/* V1 Badge */}
{asset.must_have_for_v1 && (
<div className="absolute top-2 right-2">
<Badge variant="default" className="shadow-sm bg-blue-600">
V1
</Badge>
</div>
)}
{/* Asset Type Badge */}
<div className="absolute top-2 left-2">
<Badge variant="secondary" className="shadow-sm text-xs">
{asset.asset_type.replace('_', ' ')}
</Badge>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
</div>
{/* Card Content */}
<div className="p-4 space-y-3">
<div>
<h3 className="font-semibold text-sm mb-2">{asset.name}</h3>
<p className="text-xs text-muted-foreground line-clamp-2">
{asset.asset_metadata?.why_it_exists}
</p>
</div>
{asset.asset_metadata?.visual_style_notes && (
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
<span className="font-medium">Style:</span>{" "}
{asset.asset_metadata.visual_style_notes}
</p>
</div>
)}
<div className="flex items-center justify-between pt-2">
<Badge variant="outline" className="text-xs">
{asset.asset_metadata?.which_user_it_serves || "All users"}
</Badge>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs gap-1"
onClick={(e) => {
e.stopPropagation();
toast.info("Opening in designer...");
}}
>
<Sparkles className="h-3 w-3" />
Design
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
)}
{/* Existing Work Items */}
{selectedStageData.items.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide">
Existing Work
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{selectedStageData.items.map((item) => (
<Card key={item.id} className="p-4 hover:bg-accent/50 transition-colors">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{getStatusIcon(item.status)}
<h3 className="font-semibold text-sm">{item.title}</h3>
</div>
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{item.path}
</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{item.sessionsCount} sessions</span>
<span></span>
<span>{item.commitsCount} commits</span>
</div>
<Badge variant="outline" className="text-xs">
{item.status === "built"
? "Done"
: item.status === "in_progress"
? "In Progress"
: "To-do"}
</Badge>
</div>
</Card>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
{/* End Main Content */}
</div>
);
}