VIBN Frontend for Coolify deployment
This commit is contained in:
546
app/[workspace]/project/[projectId]/journey/page.tsx
Normal file
546
app/[workspace]/project/[projectId]/journey/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user