From 83f2b7528dafe4329b169fa1101162326eb74b6b Mon Sep 17 00:00:00 2001 From: Rafael Caixeta Date: Sat, 27 Sep 2025 12:27:28 -0700 Subject: [PATCH 1/7] Transform canvas starter into Pitch Platform - AI-powered sales training - Replace example pages with pitch-focused structure - Add Companies list page for browsing target companies - Create Company Details page with split-view pitch interface - Implement Seller's Pitch Score assessment tool - Update navigation and branding for pitch platform - Add comprehensive documentation (PITCH_PLATFORM_OVERVIEW.md) - Remove unused example pages (dashboard, settings, projects, team) --- PITCH_PLATFORM_OVERVIEW.md | 102 ++ README.md | 14 +- ROUTING_GUIDE.md | 316 ++++++ src/app/companies/page.tsx | 170 ++++ src/app/company/[id]/page.tsx | 246 +++++ src/app/layout-old.tsx | 38 + src/app/layout.tsx | 11 +- src/app/page.tsx | 1799 +-------------------------------- src/app/pitch-score/page.tsx | 314 ++++++ src/components/navigation.tsx | 99 ++ 10 files changed, 1313 insertions(+), 1796 deletions(-) create mode 100644 PITCH_PLATFORM_OVERVIEW.md create mode 100644 ROUTING_GUIDE.md create mode 100644 src/app/companies/page.tsx create mode 100644 src/app/company/[id]/page.tsx create mode 100644 src/app/layout-old.tsx create mode 100644 src/app/pitch-score/page.tsx create mode 100644 src/components/navigation.tsx diff --git a/PITCH_PLATFORM_OVERVIEW.md b/PITCH_PLATFORM_OVERVIEW.md new file mode 100644 index 0000000..8dee7c7 --- /dev/null +++ b/PITCH_PLATFORM_OVERVIEW.md @@ -0,0 +1,102 @@ +# Pitch Platform Overview + +## 🎯 Platform Purpose + +The Pitch Platform is an AI-powered sales training and assessment tool where: +- **Sellers** practice pitching products/services to companies +- **Buyers** (company representatives) evaluate seller performance +- **AI Assistant** provides real-time coaching and guidance during pitches + +## πŸ“± Key Pages + +### 1. Companies List (`/companies`) +- Browse all available companies to pitch to +- See company size, industry, and job openings +- Filter by status (active, pending, contacted) +- Request new companies to be added + +### 2. Company Details & Pitch Interface (`/company/[id]`) +**Left Side: AI-Powered Chat** +- Real-time pitch assistant powered by CopilotKit +- Helps sellers craft effective pitches +- Provides objection handling suggestions +- Guides through the sales process + +**Right Side: Company Intelligence** +- Company overview and description +- Current needs and pain points +- Existing tech stack/solutions +- Key decision makers with focus areas +- Job openings and growth indicators + +### 3. Seller's Pitch Score (`/pitch-score`) +- Comprehensive assessment tool for buyers +- Six evaluation criteria: + - Product Knowledge (20% weight) + - Communication (15% weight) + - Needs Analysis (20% weight) + - Solution Fit (20% weight) + - Objection Handling (15% weight) + - Professionalism (10% weight) +- Overall score calculation +- Recommendation system +- Export assessment reports + +## πŸ”„ User Flow + +### For Sellers: +1. Browse companies on `/companies` +2. Select a target company +3. Enter pitch interface at `/company/[id]` +4. Use AI assistant to prepare and deliver pitch +5. Receive feedback via pitch scores + +### For Buyers: +1. Access company pitch sessions +2. Observe seller presentations +3. Navigate to `/pitch-score` after pitch +4. Complete detailed assessment +5. Submit evaluation for seller improvement + +## πŸ€– AI Integration + +The platform leverages CopilotKit throughout: +- **Company Finder Assistant**: Helps sellers identify ideal prospects +- **Pitch Coach**: Real-time guidance during presentations +- **Assessment Helper**: Suggests constructive feedback based on scores + +## πŸ› οΈ Technical Stack + +- **Frontend**: Next.js 15 with App Router +- **UI**: Tailwind CSS + shadcn/ui components +- **AI**: CopilotKit with LlamaIndex backend +- **State**: React hooks with CopilotKit's useCoAgent +- **Navigation**: File-based routing with dynamic segments + +## πŸ“Š Data Model + +### Company +- Basic info (name, industry, size) +- Needs and challenges +- Current solutions +- Decision makers + +### Pitch Session +- Seller information +- Company target +- Chat transcript +- Duration and engagement metrics + +### Assessment +- Scored criteria +- Overall recommendation +- Detailed feedback +- Historical comparisons + +## πŸš€ Next Steps + +1. **Authentication**: Add seller/buyer login system +2. **Database**: Connect to real company data +3. **Analytics**: Track pitch success rates +4. **Training Mode**: Practice pitches with AI buyers +5. **Leaderboards**: Gamify seller performance diff --git a/README.md b/README.md index 69e62a8..8322571 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ -# Fullstack Agents Hackathon Starter +# Pitch Platform - AI-Powered Sales Training -Welcome to the Fullstack Agents hackathon! This starter gives you a complete AI-powered canvas application with real-world integrations. Utilizing [LlamaIndex](https://developers.llamaindex.ai), [Composio](https://docs.composio.dev), and [CopilotKit](https://docs.copilotkit.ai). +An intelligent platform where sellers practice pitching to companies with real-time AI coaching and performance assessment. Built with [LlamaIndex](https://developers.llamaindex.ai), [Composio](https://docs.composio.dev), and [CopilotKit](https://docs.copilotkit.ai). -## About this starter -This is a starter template for building AI-powered canvas applications using LlamaIndex, CopilotKit, and Composio. It provides a modern Next.js application with an integrated LlamaIndex agent that manages a visual canvas of interactive cards with real-time AI synchronization and external tool integrations (Google Sheets, for this example) through Composio. +## About this Platform +This is an AI-powered pitch training platform that helps sellers improve their sales skills through: +- **Interactive Pitch Sessions**: Practice pitching to real companies with AI guidance +- **Real-time Coaching**: Get instant feedback and suggestions during your pitch +- **Performance Assessment**: Buyers evaluate sellers using a comprehensive scoring system +- **Company Intelligence**: Access detailed information about target companies -This is an example application that we built to help you get started quickly. Everything you see can be customized, replaced, augmented or built upon. +Built on the CopilotKit canvas starter template, this platform demonstrates how AI can transform sales training and assessment. https://github.com/user-attachments/assets/2a4ec718-b83b-4968-9cbe-7c1fe082e958 diff --git a/ROUTING_GUIDE.md b/ROUTING_GUIDE.md new file mode 100644 index 0000000..931fe6c --- /dev/null +++ b/ROUTING_GUIDE.md @@ -0,0 +1,316 @@ +# Routing Guide for Pitch Platform + +## Overview + +This is an AI-powered pitch platform where sellers practice and deliver pitches to companies (buyers) through an interactive chat interface. The platform uses **Next.js 15 with App Router** and integrates CopilotKit for AI assistance throughout the pitch process. + +## File-Based Routing + +### Basic Routes + +Create routes by adding `page.tsx` files in the `src/app/` directory: + +``` +src/app/ +β”œβ”€β”€ page.tsx β†’ / (redirects to /companies) +β”œβ”€β”€ companies/ +β”‚ └── page.tsx β†’ /companies (list of companies) +β”œβ”€β”€ company/ +β”‚ └── [id]/ +β”‚ └── page.tsx β†’ /company/[id] (pitch interface) +└── pitch-score/ + └── page.tsx β†’ /pitch-score (assessment tool) +``` + +### Dynamic Routes + +For dynamic segments, use square brackets: +- `[id]` - Single dynamic segment: `/projects/123` +- `[...slug]` - Catch-all segments: `/blog/2024/01/post-title` +- `[[...slug]]` - Optional catch-all: `/docs` or `/docs/guide/routing` + +## Creating New Pages + +### 1. Basic Page Template + +```tsx +"use client"; + +import { useCopilotAction } from "@copilotkit/react-core"; +import { CopilotPopup } from "@copilotkit/react-ui"; + +export default function YourPage() { + // Add CopilotKit actions + useCopilotAction({ + name: "your_action", + description: "Description of what this action does", + parameters: [ + { + name: "param", + type: "string", + description: "Parameter description", + required: true, + }, + ], + handler: async ({ param }) => { + // Your logic here + return `Action completed with ${param}`; + }, + }); + + return ( +
+ {/* Your page content */} + + {/* Optional: Add CopilotKit chat */} + +
+ ); +} +``` + +### 2. Page with Navigation + +To add navigation between pages, use Next.js `Link` component: + +```tsx +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function PageWithNav() { + return ( +
+ + + + + {/* Programmatic navigation */} + +
+ ); +} +``` + +### 3. Dynamic Route Page + +For pages with dynamic parameters: + +```tsx +"use client"; + +import { use } from "react"; + +interface PageProps { + params: Promise<{ + id: string; + }>; +} + +export default function DynamicPage({ params }: PageProps) { + // In Next.js 15, params is a Promise + const { id } = use(params); + + return
Item ID: {id}
; +} +``` + +## Navigation Components + +### Using the Navigation Component + +The project includes a navigation component. To use it, update your root layout: + +```tsx +// src/app/layout.tsx +import { Navigation, MobileNavigation } from "@/components/navigation"; + +export default function RootLayout({ children }) { + return ( + + + + +
+ {children} +
+ +
+ + + ); +} +``` + +## CopilotKit Integration + +### Available CopilotKit Features per Page + +1. **CopilotPopup** - Floating chat interface +2. **CopilotChat** - Embedded chat component +3. **useCopilotAction** - Define custom actions +4. **useCoAgent** - State synchronization (already used in main canvas) +5. **useCopilotChat** - Access chat functionality programmatically + +### Example: Full-Featured Page + +```tsx +"use client"; + +import { + useCopilotAction, + useCopilotChat, + useCopilotReadable +} from "@copilotkit/react-core"; +import { CopilotChat } from "@copilotkit/react-ui"; + +export default function FeatureRichPage() { + const [data, setData] = useState({ count: 0 }); + + // Make data readable by AI + useCopilotReadable({ + description: "Current page data", + value: data, + }); + + // Define actions + useCopilotAction({ + name: "increment_count", + description: "Increment the counter", + handler: () => { + setData(prev => ({ count: prev.count + 1 })); + return "Counter incremented"; + }, + }); + + // Access chat programmatically + const { appendMessage } = useCopilotChat(); + + return ( +
+
+ {/* Page content */} +

Count: {data.count}

+ +
+ + +
+ ); +} +``` + +## Best Practices + +1. **Client Components**: Use `"use client"` directive for pages using CopilotKit hooks +2. **Loading States**: Add loading.tsx files for better UX during navigation +3. **Error Handling**: Create error.tsx files to handle page-level errors +4. **Metadata**: Export metadata for SEO in each page +5. **State Management**: Consider using the existing `useCoAgent` pattern for complex state + +## Common Patterns + +### Protected Routes + +```tsx +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function ProtectedPage() { + const router = useRouter(); + + useEffect(() => { + // Check authentication + const isAuthenticated = checkAuth(); + if (!isAuthenticated) { + router.push("/login"); + } + }, [router]); + + return
Protected content
; +} +``` + +### Shared Layouts + +Create layout.tsx in any directory to share UI: + +```tsx +// src/app/dashboard/layout.tsx +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ +
+ {children} +
+
+ ); +} +``` + +## API Routes + +For backend functionality, create route handlers: + +```tsx +// src/app/api/custom/route.ts +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + return NextResponse.json({ data: "value" }); +} + +export async function POST(request: Request) { + const body = await request.json(); + return NextResponse.json({ received: body }); +} +``` + +## Testing Your Routes + +Run the development server and test: + +```bash +npm run dev +``` + +Navigate to: +- http://localhost:3000/companies - Companies list (main landing) +- http://localhost:3000/company/1 - Company pitch interface +- http://localhost:3000/pitch-score?company=1 - Seller assessment tool + +## Troubleshooting + +1. **Page Not Found**: Ensure file is named `page.tsx` (not `index.tsx`) +2. **Hydration Errors**: Check for mismatched server/client rendering +3. **CopilotKit Not Working**: Verify page is wrapped in root layout's CopilotKit provider +4. **Dynamic Routes**: Remember to use `use()` hook for params in Next.js 15 + +## Next Steps + +1. Add more pages following the patterns above +2. Implement proper data fetching (API routes or external APIs) +3. Add authentication/authorization as needed +4. Customize the navigation component for your needs +5. Integrate more CopilotKit features based on page requirements diff --git a/src/app/companies/page.tsx b/src/app/companies/page.tsx new file mode 100644 index 0000000..9e3b93a --- /dev/null +++ b/src/app/companies/page.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Building2, Users, Briefcase, ArrowRight } from "lucide-react"; +import { useCopilotAction } from "@copilotkit/react-core"; +import { CopilotPopup } from "@copilotkit/react-ui"; + +interface Company { + id: string; + name: string; + industry: string; + employees: string; + jobOpenings: number; + status: "active" | "pending" | "contacted"; +} + +export default function CompaniesPage() { + const [companies, setCompanies] = useState([ + { + id: "1", + name: "TechCorp Solutions", + industry: "Software Development", + employees: "500-1000", + jobOpenings: 12, + status: "active", + }, + { + id: "2", + name: "Global Manufacturing Inc", + industry: "Manufacturing", + employees: "1000-5000", + jobOpenings: 8, + status: "pending", + }, + { + id: "3", + name: "Healthcare Innovations", + industry: "Healthcare", + employees: "100-500", + jobOpenings: 15, + status: "active", + }, + { + id: "4", + name: "Finance Leaders Ltd", + industry: "Financial Services", + employees: "5000+", + jobOpenings: 20, + status: "contacted", + }, + ]); + + // CopilotKit action to help sellers find companies + useCopilotAction({ + name: "find_companies", + description: "Help sellers find companies that match their expertise", + parameters: [ + { + name: "criteria", + type: "string", + description: "Search criteria (industry, size, job openings)", + required: true, + }, + ], + handler: async ({ criteria }) => { + // In a real app, this would search your database + const matchingCompanies = companies.filter(company => + company.industry.toLowerCase().includes(criteria.toLowerCase()) || + company.name.toLowerCase().includes(criteria.toLowerCase()) + ); + return `Found ${matchingCompanies.length} companies matching "${criteria}"`; + }, + }); + + const getStatusColor = (status: Company["status"]) => { + switch (status) { + case "active": + return "bg-green-50 text-green-700"; + case "pending": + return "bg-yellow-50 text-yellow-700"; + case "contacted": + return "bg-blue-50 text-blue-700"; + } + }; + + return ( +
+
+
+

Companies

+

+ Browse companies and start your pitch journey +

+
+ +
+ {companies.map((company) => ( + +
+
+
+ +
+

{company.name}

+

+ {company.industry} +

+
+
+ +
+ +
+
+ + {company.employees} employees +
+ +
+ + {company.jobOpenings} job openings +
+ +
+ + {company.status.charAt(0).toUpperCase() + company.status.slice(1)} + + +
+
+
+ + ))} +
+ +
+

Can't find your target company?

+

+ Request to add a new company to our platform +

+ +
+
+ + +
+ ); +} diff --git a/src/app/company/[id]/page.tsx b/src/app/company/[id]/page.tsx new file mode 100644 index 0000000..60de320 --- /dev/null +++ b/src/app/company/[id]/page.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { use } from "react"; +import { useCoAgent, useCopilotAction, useCopilotAdditionalInstructions } from "@copilotkit/react-core"; +import { CopilotKitCSSProperties, CopilotChat } from "@copilotkit/react-ui"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { Building2, Users, Briefcase, Globe, Target, ArrowLeft } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface PageProps { + params: Promise<{ + id: string; + }>; +} + +interface CompanyDetails { + id: string; + name: string; + industry: string; + employees: string; + website: string; + description: string; + jobOpenings: number; + needs: string[]; + challenges: string[]; + currentSolutions: string[]; + decisionMakers: { + name: string; + role: string; + focus: string; + }[]; +} + +// Mock company data - in real app, fetch from API +const getCompanyDetails = (id: string): CompanyDetails => ({ + id, + name: "TechCorp Solutions", + industry: "Software Development", + employees: "500-1000", + website: "www.techcorp.com", + description: "A leading software development company specializing in enterprise solutions, cloud computing, and AI-driven applications.", + jobOpenings: 12, + needs: [ + "Cloud infrastructure optimization", + "DevOps tooling and automation", + "Cybersecurity solutions", + "Employee training platforms", + ], + challenges: [ + "Scaling development teams efficiently", + "Maintaining code quality at scale", + "Reducing time-to-market for new features", + "Managing multi-cloud environments", + ], + currentSolutions: [ + "AWS for cloud hosting", + "Jenkins for CI/CD", + "Slack for communication", + "Jira for project management", + ], + decisionMakers: [ + { name: "Sarah Johnson", role: "CTO", focus: "Technology strategy and innovation" }, + { name: "Mike Chen", role: "VP Engineering", focus: "Development processes and team efficiency" }, + { name: "Lisa Brown", role: "Director of IT", focus: "Infrastructure and security" }, + ], +}); + +export default function CompanyDetailsPage({ params }: PageProps) { + const { id } = use(params); + const company = getCompanyDetails(id); + + // Add company context to the AI + useCopilotAdditionalInstructions( + `You are helping a seller pitch to ${company.name}, a ${company.industry} company with ${company.employees} employees. + The company's main needs are: ${company.needs.join(", ")}. + Their current challenges include: ${company.challenges.join(", ")}. + Key decision makers are: ${company.decisionMakers.map(dm => `${dm.name} (${dm.role})`).join(", ")}. + + Help the seller craft effective pitches, handle objections, and close deals. Be consultative and focus on solving the company's specific problems.` + ); + + // Define actions for the pitch process + useCopilotAction({ + name: "analyze_company_need", + description: "Analyze a specific company need and suggest how to address it", + parameters: [ + { + name: "need", + type: "string", + description: "The specific need to analyze", + required: true, + }, + ], + handler: async ({ need }) => { + return `Analyzing ${need} for ${company.name}...`; + }, + }); + + useCopilotAction({ + name: "generate_pitch_opener", + description: "Generate an effective pitch opener for this company", + parameters: [ + { + name: "decision_maker", + type: "string", + description: "The decision maker you're pitching to", + required: true, + }, + ], + handler: async ({ decision_maker }) => { + return `Generated opener for ${decision_maker} at ${company.name}`; + }, + }); + + return ( +
+
+
+ + + + + + +
+
+ +
+ {/* Chat Section - Left Side */} +
+ +
+ + {/* Company Details - Right Side */} +
+
+ {/* Company Header */} +
+ +
+

{company.name}

+

{company.industry}

+
+ + + {company.employees} + + + + {company.website} + + + + {company.jobOpenings} openings + +
+
+
+ + {/* Company Description */} +
+

About

+

{company.description}

+
+ + {/* Company Needs */} +
+

+ + Current Needs +

+
    + {company.needs.map((need, index) => ( +
  • + β€’ + {need} +
  • + ))} +
+
+ + {/* Challenges */} +
+

Pain Points & Challenges

+
    + {company.challenges.map((challenge, index) => ( +
  • + β€’ + {challenge} +
  • + ))} +
+
+ + {/* Current Solutions */} +
+

Current Tech Stack

+
+ {company.currentSolutions.map((solution, index) => ( + + {solution} + + ))} +
+
+ + {/* Decision Makers */} +
+

Key Decision Makers

+
+ {company.decisionMakers.map((person, index) => ( +
+
+ + {person.name.split(" ").map(n => n[0]).join("")} + +
+
+

{person.name}

+

{person.role}

+

Focus: {person.focus}

+
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/src/app/layout-old.tsx b/src/app/layout-old.tsx new file mode 100644 index 0000000..2e5ce1d --- /dev/null +++ b/src/app/layout-old.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; + +import { Manrope } from "next/font/google"; +import { GeistMono } from "geist/font/mono"; +import { CopilotKit } from "@copilotkit/react-core"; +import "./globals.css"; +import "@copilotkit/react-ui/styles.css"; + +const manrope = Manrope({ + subsets: ["latin"], + display: "swap", + variable: "--font-manrope", +}); + +export const metadata: Metadata = { + title: "AG-UI Canvas | CopilotKit with LlamaIndex", + description: "Generated by CopilotKit with LlamaIndex", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2e5ce1d..1430517 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { Manrope } from "next/font/google"; import { GeistMono } from "geist/font/mono"; import { CopilotKit } from "@copilotkit/react-core"; +import { Navigation, MobileNavigation } from "@/components/navigation"; import "./globals.css"; import "@copilotkit/react-ui/styles.css"; @@ -13,8 +14,8 @@ const manrope = Manrope({ }); export const metadata: Metadata = { - title: "AG-UI Canvas | CopilotKit with LlamaIndex", - description: "Generated by CopilotKit with LlamaIndex", + title: "Pitch Platform | AI-Powered Sales Training", + description: "Platform for sellers to pitch to companies with AI-powered assistance and performance assessment", }; export default function RootLayout({ @@ -30,7 +31,11 @@ export default function RootLayout({ agent="sample_agent" publicApiKey={process.env.COPILOT_CLOUD_PUBLIC_API_KEY} // optional (for CopilotKit Cloud features) > - {children} + +
+ {children} +
+ diff --git a/src/app/page.tsx b/src/app/page.tsx index 882f642..46a718f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,1799 +1,22 @@ "use client"; -import { useCoAgent, useCopilotAction, useCopilotAdditionalInstructions } from "@copilotkit/react-core"; -import { CopilotKitCSSProperties, CopilotChat, CopilotPopup } from "@copilotkit/react-ui"; -import { useCallback, useEffect, useRef, useState } from "react"; -import type React from "react"; -import { Button } from "@/components/ui/button" -import AppChatHeader, { PopupHeader } from "@/components/canvas/AppChatHeader"; -import { X } from "lucide-react" -import CardRenderer from "@/components/canvas/CardRenderer"; -import ShikiHighlighter from "react-shiki/web"; -import { motion, useScroll, useTransform, useMotionValueEvent } from "motion/react"; -import { EmptyState } from "@/components/empty-state"; -import { cn, getContentArg } from "@/lib/utils"; -import type { AgentState, Item, ItemData, ProjectData, EntityData, NoteData, ChartData, CardType } from "@/lib/canvas/types"; -import { initialState, isNonEmptyAgentState } from "@/lib/canvas/state"; -import { projectAddField4Item, projectSetField4ItemText, projectSetField4ItemDone, projectRemoveField4Item, chartAddField1Metric, chartSetField1Label, chartSetField1Value, chartRemoveField1Metric } from "@/lib/canvas/updates"; -import useMediaQuery from "@/hooks/use-media-query"; -import ItemHeader from "@/components/canvas/ItemHeader"; -import NewItemMenu from "@/components/canvas/NewItemMenu"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; -export default function CopilotKitPage() { - const { state, setState } = useCoAgent({ - name: "sample_agent", - initialState, - }); - +export default function HomePage() { + const router = useRouter(); - // Global cache for the last non-empty agent state - const cachedStateRef = useRef(state ?? initialState); useEffect(() => { - if (isNonEmptyAgentState(state)) { - cachedStateRef.current = state as AgentState; - } - }, [state]); - // we use viewState to avoid transient flicker; TODO: troubleshoot and remove this workaround - const viewState: AgentState = isNonEmptyAgentState(state) ? (state as AgentState) : cachedStateRef.current; - - const isDesktop = useMediaQuery("(min-width: 768px)"); - const [showJsonView, setShowJsonView] = useState(false); - const scrollAreaRef = useRef(null); - const { scrollY } = useScroll({ container: scrollAreaRef }); - const headerScrollThreshold = 64; - const headerOpacity = useTransform(scrollY, [0, headerScrollThreshold], [1, 0]); - const [headerDisabled, setHeaderDisabled] = useState(false); - const titleInputRef = useRef(null); - const descTextareaRef = useRef(null); - const lastCreationRef = useRef<{ type: CardType; name: string; id: string; ts: number } | null>(null); - const lastChecklistCreationRef = useRef>({}); - const lastMetricCreationRef = useRef>({}); - - useMotionValueEvent(scrollY, "change", (y) => { - const disable = y >= headerScrollThreshold; - setHeaderDisabled(disable); - if (disable) { - titleInputRef.current?.blur(); - descTextareaRef.current?.blur(); - } - }); - - useEffect(() => { - console.log("[CoAgent state updated]", state); - - // Auto-sync to Google Sheets if syncSheetId is present - const autoSyncToSheets = async () => { - console.log("[AUTO-SYNC] Checking sync conditions:", { - hasState: !!state, - syncSheetId: state?.syncSheetId, - itemsLength: state?.items?.length || 0 - }); - - if (!state || !state.syncSheetId) { - console.log("[AUTO-SYNC] Skipping - no sheet configured"); - return; // No sync needed - no sheet configured - } - - try { - console.log(`[AUTO-SYNC] Syncing ${state.items?.length || 0} items to sheet: ${state.syncSheetId}`); - - const response = await fetch('/api/sheets/sync', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - canvas_state: state, - sheet_id: state.syncSheetId, - sheet_name: state.syncSheetName, - }), - }); - - if (response.ok) { - const result = await response.json(); - console.log('[AUTO-SYNC] βœ… Successfully synced to Google Sheets:', result.message); - } else { - const error = await response.json(); - console.warn('[AUTO-SYNC] ❌ Failed to sync to Google Sheets:', error.error); - } - } catch (error) { - console.warn('[AUTO-SYNC] ❌ Exception during auto-sync:', error); - } - }; - - // Debounce the sync to avoid too many requests - const timeoutId = setTimeout(autoSyncToSheets, 1000); - return () => clearTimeout(timeoutId); - }, [state]); - - // Reset JSON view when there are no items - useEffect(() => { - const itemsCount = (viewState?.items ?? []).length; - if (itemsCount === 0 && showJsonView) { - setShowJsonView(false); - } - }, [viewState?.items, showJsonView]); - - - - const getStatePreviewJSON = (s: AgentState | undefined): Record => { - const snapshot = (s ?? initialState) as AgentState; - const { globalTitle, globalDescription, items } = snapshot; - return { - globalTitle: globalTitle ?? initialState.globalTitle, - globalDescription: globalDescription ?? initialState.globalDescription, - items: items ?? initialState.items, - }; - }; - - - // Strengthen grounding: always prefer shared state over chat history - useCopilotAdditionalInstructions({ - instructions: (() => { - const items = viewState.items ?? initialState.items; - const gTitle = viewState.globalTitle ?? ""; - const gDesc = viewState.globalDescription ?? ""; - const summary = items - .slice(0, 5) - .map((p: Item) => `id=${p.id} β€’ name=${p.name} β€’ type=${p.type}`) - .join("\n"); - const fieldSchema = [ - "FIELD SCHEMA (authoritative):", - "- project.data:", - " - field1: string (text)", - " - field2: string (select: 'Option A' | 'Option B' | 'Option C'; empty string means unset)", - " - field3: string (date 'YYYY-MM-DD')", - " - field4: ChecklistItem[] where ChecklistItem={id: string, text: string, done: boolean, proposed: boolean}", - "- entity.data:", - " - field1: string", - " - field2: string (select: 'Option A' | 'Option B' | 'Option C'; empty string means unset)", - " - field3: string[] (selected tags; subset of field3_options)", - " - field3_options: string[] (available tags)", - "- note.data:", - " - field1: string (textarea)", - "- chart.data:", - " - field1: Array<{id: string, label: string, value: number | ''}> with value in [0..100] or ''", - ].join("\n"); - const toolUsageHints = [ - "TOOL USAGE HINTS:", - "- To create cards, call createItem with { type: 'project' | 'entity' | 'note' | 'chart', name?: string } and use returned id.", - "- Prefer calling specific actions: setProjectField1, setProjectField2, setProjectField3, addProjectChecklistItem, setProjectChecklistItem, removeProjectChecklistItem.", - "- field2 values: 'Option A' | 'Option B' | 'Option C' | '' (empty clears).", - "- field3 accepts natural dates (e.g., 'tomorrow', '2025-01-30'); it will be normalized to YYYY-MM-DD.", - "- Checklist edits accept either the generated id (e.g., '001') or a numeric index (e.g., '1', 1-based).", - "- For charts, values are clamped to [0..100]; use clearChartField1Value to clear an existing metric value.", - "- Card subtitle/description keywords (description, overview, summary, caption, blurb) map to setItemSubtitleOrDescription. Never write these to data.field1 for non-note items.", - "LOOP CONTROL: When asked to 'add a couple' items, add at most 2 and stop. Avoid repeated calls to the same mutating tool in one turn.", - "RANDOMIZATION: If the user specifically asks for random/mock values, you MAY generate and set them right away using the tools (do not block for more details).", - "VERIFICATION: After tools run, re-read the latest state and confirm what actually changed.", - ].join("\n"); - return [ - "ALWAYS ANSWER FROM SHARED STATE (GROUND TRUTH).", - "If a command does not specify which item to change, ask the user to clarify before proceeding.", - `Global Title: ${gTitle || "(none)"}`, - `Global Description: ${gDesc || "(none)"}`, - "Items (sample):", - summary || "(none)", - fieldSchema, - toolUsageHints, - ].join("\n"); - })(), - }); - - // Tool-based HITL: choose item - useCopilotAction({ - name: "choose_item", - description: "Ask the user to choose an item id from the canvas.", - available: "remote", - parameters: [ - { name: "content", type: "string", required: false, description: "Prompt to display." }, - ], - renderAndWaitForResponse: ({ respond, args }) => { - const items = viewState.items ?? initialState.items; - if (!items.length) { - return ( -
-

No items available.

-
- ); - } - let selectedId = items[0].id; - return ( -
-

Select an item

-

{getContentArg(args) ?? "Which item should I use?"}

- -
- - -
-
- ); - }, - }); - - // Tool-based HITL: choose card type - useCopilotAction({ - name: "choose_card_type", - description: "Ask the user to choose a card type to create.", - available: "remote", - parameters: [ - { name: "content", type: "string", required: false, description: "Prompt to display." }, - ], - renderAndWaitForResponse: ({ respond, args }) => { - const options: { id: CardType; label: string }[] = [ - { id: "project", label: "Project" }, - { id: "entity", label: "Entity" }, - { id: "note", label: "Note" }, - { id: "chart", label: "Chart" }, - ]; - let selected: CardType | "" = ""; - return ( -
-

Select a card type

-

{getContentArg(args) ?? "Which type of card should I create?"}

- -
- - -
-
- ); - }, - }); - - const updateItem = useCallback( - (itemId: string, updates: Partial) => { - setState((prev) => { - const base = prev ?? initialState; - const items: Item[] = base.items ?? []; - const nextItems = items.map((p) => (p.id === itemId ? { ...p, ...updates } : p)); - return { ...base, items: nextItems } as AgentState; - }); - }, - [setState] - ); - - const updateItemData = useCallback( - (itemId: string, updater: (prev: ItemData) => ItemData) => { - setState((prev) => { - const base = prev ?? initialState; - const items: Item[] = base.items ?? []; - const nextItems = items.map((p) => (p.id === itemId ? { ...p, data: updater(p.data) } : p)); - return { ...base, items: nextItems } as AgentState; - }); - }, - [setState] - ); - - const deleteItem = useCallback((itemId: string) => { - setState((prev) => { - const base = prev ?? initialState; - const existed = (base.items ?? []).some((p) => p.id === itemId); - const items: Item[] = (base.items ?? []).filter((p) => p.id !== itemId); - return { ...base, items, lastAction: existed ? `deleted:${itemId}` : `not_found:${itemId}` } as AgentState; - }); - }, [setState]); - - // Checklist item local helper removed; Copilot actions handle checklist CRUD - - const toggleTag = useCallback((itemId: string, tag: string) => { - updateItemData(itemId, (prev) => { - const anyPrev = prev as { field3?: string[] }; - if (Array.isArray(anyPrev.field3)) { - const selected = new Set(anyPrev.field3 ?? []); - if (selected.has(tag)) selected.delete(tag); else selected.add(tag); - return { ...anyPrev, field3: Array.from(selected) } as ItemData; - } - return prev; - }); - }, [updateItemData]); - - // Remove checklist item local helper removed; use Copilot action instead - - // Helper to generate default data by type - const defaultDataFor = useCallback((type: CardType): ItemData => { - switch (type) { - case "project": - return { - field1: "", - field2: "", - field3: "", - field4: [], - field4_id: 0, - } as ProjectData; - case "entity": - return { - field1: "", - field2: "", - field3: [], - field3_options: ["Tag 1", "Tag 2", "Tag 3"], - } as EntityData; - case "note": - return { field1: "" } as NoteData; - case "chart": - return { field1: [], field1_id: 0 } as ChartData; - default: - return { content: "" } as NoteData; - } - }, []); - - const addItem = useCallback((type: CardType, name?: string) => { - const t: CardType = type; - let createdId = ""; - setState((prev) => { - const base = prev ?? initialState; - const items: Item[] = base.items ?? []; - // Derive next numeric id robustly from both itemsCreated counter and max existing id - const maxExisting = items.reduce((max, it) => { - const parsed = Number.parseInt(String(it.id ?? "0"), 10); - return Number.isFinite(parsed) ? Math.max(max, parsed) : max; - }, 0); - const priorCount = Number.isFinite(base.itemsCreated) ? (base.itemsCreated as number) : 0; - const nextNumber = Math.max(priorCount, maxExisting) + 1; - createdId = String(nextNumber).padStart(4, "0"); - const item: Item = { - id: createdId, - type: t, - name: name && name.trim() ? name.trim() : "", - subtitle: "", - data: defaultDataFor(t), - }; - const nextItems = [...items, item]; - return { ...base, items: nextItems, itemsCreated: nextNumber, lastAction: `created:${createdId}` } as AgentState; - }); - return createdId; - }, [defaultDataFor, setState]); - - - - // Frontend Actions (exposed as tools to the agent via CopilotKit) - useCopilotAction({ - name: "setGlobalTitle", - description: "Set the global title/name (outside of items).", - available: "remote", - parameters: [ - { name: "title", type: "string", required: true, description: "The new global title/name." }, - ], - handler: ({ title }: { title: string }) => { - setState((prev) => ({ ...(prev ?? initialState), globalTitle: title })); - }, - }); - - useCopilotAction({ - name: "setGlobalDescription", - description: "Set the global description/subtitle (outside of items).", - available: "remote", - parameters: [ - { name: "description", type: "string", required: true, description: "The new global description/subtitle." }, - ], - handler: ({ description }: { description: string }) => { - setState((prev) => ({ ...(prev ?? initialState), globalDescription: description })); - }, - }); - - // Frontend Actions (item-scoped) - useCopilotAction({ - name: "setItemName", - description: "Set an item's name/title.", - available: "remote", - parameters: [ - { name: "name", type: "string", required: true, description: "The new item name/title." }, - { name: "itemId", type: "string", required: true, description: "Target item id." }, - ], - handler: ({ name, itemId }: { name: string; itemId: string }) => { - updateItem(itemId, { name }); - }, - }); - - // Set item subtitle - useCopilotAction({ - name: "setItemSubtitleOrDescription", - description: "Set an item's description/subtitle (short description or subtitle).", - available: "remote", - parameters: [ - { name: "subtitle", type: "string", required: true, description: "The new item description/subtitle." }, - { name: "itemId", type: "string", required: true, description: "Target item id." }, - ], - handler: ({ subtitle, itemId }: { subtitle: string; itemId: string }) => { - updateItem(itemId, { subtitle }); - }, - }); - - - // Note-specific field updates (field numbering) - useCopilotAction({ - name: "setNoteField1", - description: "Update note content (note.data.field1).", - available: "remote", - parameters: [ - { name: "value", type: "string", required: true, description: "New content for note.data.field1." }, - { name: "itemId", type: "string", required: true, description: "Target item id (note)." }, - ], - handler: ({ value, itemId }: { value: string; itemId: string }) => { - updateItemData(itemId, (prev) => { - const nd = prev as NoteData; - if (Object.prototype.hasOwnProperty.call(nd, "field1")) { - return { ...(nd as NoteData), field1: value } as NoteData; - } - return prev; - }); - }, - }); - - useCopilotAction({ - name: "appendNoteField1", - description: "Append text to note content (note.data.field1).", - available: "remote", - parameters: [ - { name: "value", type: "string", required: true, description: "Text to append to note.data.field1." }, - { name: "itemId", type: "string", required: true, description: "Target item id (note)." }, - { name: "withNewline", type: "boolean", required: false, description: "If true, prefix with a newline." }, - ], - handler: ({ value, itemId, withNewline }: { value: string; itemId: string; withNewline?: boolean }) => { - updateItemData(itemId, (prev) => { - const nd = prev as NoteData; - if (Object.prototype.hasOwnProperty.call(nd, "field1")) { - const existing = (nd.field1 ?? ""); - const next = existing + (withNewline ? "\n" : "") + value; - return { ...(nd as NoteData), field1: next } as NoteData; - } - return prev; - }); - }, - }); - - useCopilotAction({ - name: "clearNoteField1", - description: "Clear note content (note.data.field1).", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id (note)." }, - ], - handler: ({ itemId }: { itemId: string }) => { - updateItemData(itemId, (prev) => { - const nd = prev as NoteData; - if (Object.prototype.hasOwnProperty.call(nd, "field1")) { - return { ...(nd as NoteData), field1: "" } as NoteData; - } - return prev; - }); - }, - }); - - useCopilotAction({ - name: "setProjectField1", - description: "Update project field1 (text).", - available: "remote", - parameters: [ - { name: "value", type: "string", required: true, description: "New value for field1." }, - { name: "itemId", type: "string", required: true, description: "Target item id." }, - ], - handler: ({ value, itemId }: { value: string; itemId: string }) => { - const safeValue = String((value as unknown as string) ?? ""); - updateItemData(itemId, (prev) => { - const anyPrev = prev as { field1?: string }; - if (typeof anyPrev.field1 === "string") { - return { ...anyPrev, field1: safeValue } as ItemData; - } - return prev; - }); - }, - }); - - // Project-specific field updates - useCopilotAction({ - name: "setProjectField2", - description: "Update project field2 (select).", - available: "remote", - parameters: [ - { name: "value", type: "string", required: true, description: "New value for field2." }, - { name: "itemId", type: "string", required: true, description: "Target item id." }, - ], - handler: ({ value, itemId }: { value: string; itemId: string }) => { - const safeValue = String((value as unknown as string) ?? ""); - updateItemData(itemId, (prev) => { - const anyPrev = prev as { field2?: string }; - if (typeof anyPrev.field2 === "string") { - return { ...anyPrev, field2: safeValue } as ItemData; - } - return prev; - }); - }, - }); - - useCopilotAction({ - name: "setProjectField3", - description: "Update project field3 (date, YYYY-MM-DD).", - available: "remote", - parameters: [ - { name: "date", type: "string", required: true, description: "Date in YYYY-MM-DD format." }, - { name: "itemId", type: "string", required: true, description: "Target item id." }, - ], - handler: (args: { date?: string; itemId: string } & Record) => { - const itemId = String(args.itemId); - const dictArgs = args as Record; - const rawInput = (dictArgs["date"]) ?? (dictArgs["value"]) ?? (dictArgs["val"]) ?? (dictArgs["text"]); - const normalizeDate = (input: unknown): string | null => { - if (input == null) return null; - if (input instanceof Date && !isNaN(input.getTime())) { - const yyyy = input.getUTCFullYear(); - const mm = String(input.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(input.getUTCDate()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}`; - } - const asString = String(input); - // Already in YYYY-MM-DD - if (/^\d{4}-\d{2}-\d{2}$/.test(asString)) return asString; - const parsed = new Date(asString); - if (!isNaN(parsed.getTime())) { - const yyyy = parsed.getUTCFullYear(); - const mm = String(parsed.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(parsed.getUTCDate()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}`; - } - return null; - }; - const normalized = normalizeDate(rawInput); - if (!normalized) return; - updateItemData(itemId, (prev) => { - const anyPrev = prev as { field3?: string }; - if (typeof anyPrev.field3 === "string") { - return { ...anyPrev, field3: normalized } as ItemData; - } - return prev; - }); - }, - }); - - // Clear project field3 (date) - useCopilotAction({ - name: "clearProjectField3", - description: "Clear project field3 (date).", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id." }, - ], - handler: ({ itemId }: { itemId: string }) => { - updateItemData(itemId, (prev) => { - const anyPrev = prev as { field3?: string }; - if (typeof anyPrev.field3 === "string") { - return { ...anyPrev, field3: "" } as ItemData; - } - return prev; - }); - }, - }); - - // Project field4 (checklist) CRUD - useCopilotAction({ - name: "addProjectChecklistItem", - description: "Add a new checklist item to a project.", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id (project)." }, - { name: "text", type: "string", required: false, description: "Initial checklist text (optional)." }, - ], - handler: ({ itemId, text }: { itemId: string; text?: string }) => { - const norm = (text ?? "").trim(); - // 1) If a checklist item with same text exists, return its id - const project = (viewState.items ?? initialState.items).find((it) => it.id === itemId); - if (project && project.type === "project") { - const list = ((project.data as ProjectData).field4 ?? []); - const dup = norm ? list.find((c) => (c.text ?? "").trim() === norm) : undefined; - if (dup) return dup.id; - } - // 2) Per-project throttle to avoid rapid duplicates - const now = Date.now(); - const key = `${itemId}`; - const recent = lastChecklistCreationRef.current[key]; - if (recent && recent.text === norm && now - recent.ts < 800) { - return recent.id; - } - let createdId = ""; - updateItemData(itemId, (prev) => { - const { next, createdId: id } = projectAddField4Item(prev as ProjectData, text); - createdId = id; - return next; - }); - lastChecklistCreationRef.current[key] = { text: norm, id: createdId, ts: now }; - return createdId; - }, - }); - - useCopilotAction({ - name: "setProjectChecklistItem", - description: "Update a project's checklist item text and/or done state.", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id (project)." }, - { name: "checklistItemId", type: "string", required: true, description: "Checklist item id." }, - { name: "text", type: "string", required: false, description: "New text (optional)." }, - { name: "done", type: "boolean", required: false, description: "Done status (optional)." }, - ], - handler: (args) => { - const itemId = String(args.itemId ?? ""); - const target = args.checklistItemId ?? args.itemId; - let targetId = target != null ? String(target) : ""; - const maybeDone = args.done; - const text: string | undefined = args.text != null ? String(args.text) : undefined; - const toBool = (v: unknown): boolean | undefined => { - if (typeof v === "boolean") return v; - if (typeof v === "string") { - const s = v.trim().toLowerCase(); - if (s === "true") return true; - if (s === "false") return false; - } - return undefined; - }; - const done = toBool(maybeDone); - updateItemData(itemId, (prev) => { - let next = prev as ProjectData; - const list = (next.field4 ?? []); - // If a plain numeric was provided, allow using it as index (0- or 1-based) - if (!list.some((c) => c.id === targetId) && /^\d+$/.test(targetId)) { - const n = parseInt(targetId, 10); - let idx = -1; - if (n >= 0 && n < list.length) idx = n; // 0-based - else if (n > 0 && n - 1 < list.length) idx = n - 1; // 1-based - if (idx >= 0) targetId = list[idx].id; - } - if (typeof text === "string") next = projectSetField4ItemText(next, targetId, text); - if (typeof done === "boolean") next = projectSetField4ItemDone(next, targetId, done); - return next; - }); - }, - }); - - useCopilotAction({ - name: "removeProjectChecklistItem", - description: "Remove a checklist item from a project by id.", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id (project)." }, - { name: "checklistItemId", type: "string", required: true, description: "Checklist item id to remove." }, - ], - handler: ({ itemId, checklistItemId }: { itemId: string; checklistItemId: string }) => { - updateItemData(itemId, (prev) => projectRemoveField4Item(prev as ProjectData, checklistItemId)); - }, - }); - - // Entity field updates and field3 (tags) - useCopilotAction({ - name: "setEntityField1", - description: "Update entity field1 (text).", - available: "remote", - parameters: [ - { name: "value", type: "string", required: true, description: "New value for field1." }, - { name: "itemId", type: "string", required: true, description: "Target item id (entity)." }, - ], - handler: ({ value, itemId }: { value: string; itemId: string }) => { - updateItemData(itemId, (prev) => { - const anyPrev = prev; - if (typeof anyPrev.field1 === "string") { - return { ...anyPrev, field1: value } as ItemData; - } - return prev; - }); - }, - }); - - useCopilotAction({ - name: "setEntityField2", - description: "Update entity field2 (select).", - available: "remote", - parameters: [ - { name: "value", type: "string", required: true, description: "New value for field2." }, - { name: "itemId", type: "string", required: true, description: "Target item id (entity)." }, - ], - handler: ({ value, itemId }: { value: string; itemId: string }) => { - updateItemData(itemId, (prev) => { - const anyPrev = prev as { field2?: string }; - if (typeof anyPrev.field2 === "string") { - return { ...anyPrev, field2: value } as ItemData; - } - return prev; - }); - }, - }); - - useCopilotAction({ - name: "addEntityField3", - description: "Add a tag to entity field3 (tags) if not present.", - available: "remote", - parameters: [ - { name: "tag", type: "string", required: true, description: "Tag to add." }, - { name: "itemId", type: "string", required: true, description: "Target item id (entity)." }, - ], - handler: ({ tag, itemId }: { tag: string; itemId: string }) => { - updateItemData(itemId, (prev) => { - const e = prev as EntityData; - const current = new Set((e.field3 ?? []) as string[]); - current.add(tag); - return { ...e, field3: Array.from(current) } as EntityData; - }); - }, - }); - - useCopilotAction({ - name: "removeEntityField3", - description: "Remove a tag from entity field3 (tags) if present.", - available: "remote", - parameters: [ - { name: "tag", type: "string", required: true, description: "Tag to remove." }, - { name: "itemId", type: "string", required: true, description: "Target item id (entity)." }, - ], - handler: ({ tag, itemId }: { tag: string; itemId: string }) => { - updateItemData(itemId, (prev) => { - const e = prev as EntityData; - return { ...e, field3: ((e.field3 ?? []) as string[]).filter((t) => t !== tag) } as EntityData; - }); - }, - }); - - // Chart field1 (metrics) CRUD - useCopilotAction({ - name: "addChartField1", - description: "Add a new metric (field1 entries).", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id (chart)." }, - { name: "label", type: "string", required: false, description: "Metric label (optional)." }, - { name: "value", type: "number", required: false, description: "Metric value 0..100 (optional)." }, - ], - handler: ({ itemId, label, value }: { itemId: string; label?: string; value?: number }) => { - const normLabel = (label ?? "").trim(); - // 1) If a metric with same label exists, return its id - const item = (viewState.items ?? initialState.items).find((it) => it.id === itemId); - if (item && item.type === "chart") { - const list = ((item.data as ChartData).field1 ?? []); - const dup = normLabel ? list.find((m) => (m.label ?? "").trim() === normLabel) : undefined; - if (dup) return dup.id; - } - // 2) Per-chart throttle to avoid rapid duplicates - const now = Date.now(); - const key = `${itemId}`; - const recent = lastMetricCreationRef.current[key]; - const valKey: number | "" = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : ""; - if (recent && recent.label === normLabel && recent.value === valKey && now - recent.ts < 800) { - return recent.id; - } - let createdId = ""; - updateItemData(itemId, (prev) => { - const { next, createdId: id } = chartAddField1Metric(prev as ChartData, label, value); - createdId = id; - return next; - }); - lastMetricCreationRef.current[key] = { label: normLabel, value: valKey, id: createdId, ts: now }; - return createdId; - }, - }); - - useCopilotAction({ - name: "setChartField1Label", - description: "Update chart field1 entry label by index.", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id (chart)." }, - { name: "index", type: "number", required: true, description: "Metric index (0-based)." }, - { name: "label", type: "string", required: true, description: "New metric label." }, - ], - handler: ({ itemId, index, label }: { itemId: string; index: number; label: string }) => { - updateItemData(itemId, (prev) => chartSetField1Label(prev as ChartData, index, label)); - }, - }); - - useCopilotAction({ - name: "setChartField1Value", - description: "Update chart field1 entry value by index (0..100).", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id (chart)." }, - { name: "index", type: "number", required: true, description: "Metric index (0-based)." }, - { name: "value", type: "number", required: true, description: "Metric value 0..100." }, - ], - handler: ({ itemId, index, value }: { itemId: string; index: number; value: number }) => { - updateItemData(itemId, (prev) => chartSetField1Value(prev as ChartData, index, value)); - }, - }); - - // Clear chart metric value by index - useCopilotAction({ - name: "clearChartField1Value", - description: "Clear chart field1 entry value by index (sets to empty).", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id (chart)." }, - { name: "index", type: "number", required: true, description: "Metric index (0-based)." }, - ], - handler: ({ itemId, index }: { itemId: string; index: number }) => { - updateItemData(itemId, (prev) => chartSetField1Value(prev as ChartData, index, "")); - }, - }); - - useCopilotAction({ - name: "removeChartField1", - description: "Remove a chart field1 entry by index.", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id (chart)." }, - { name: "index", type: "number", required: true, description: "Metric index (0-based)." }, - ], - handler: ({ itemId, index }: { itemId: string; index: number }) => { - updateItemData(itemId, (prev) => chartRemoveField1Metric(prev as ChartData, index)); - }, - }); - - useCopilotAction({ - name: "createItem", - description: "Create a new item.", - available: "remote", - parameters: [ - { name: "type", type: "string", required: true, description: "One of: project, entity, note, chart." }, - { name: "name", type: "string", required: false, description: "Optional item name." }, - ], - handler: ({ type, name }: { type: string; name?: string }) => { - const t = (type as CardType); - const normalized = (name ?? "").trim(); - - // 1) Name-based idempotency: if an item with same type+name exists, return it - if (normalized) { - const existing = (viewState.items ?? initialState.items).find((it) => it.type === t && (it.name ?? "").trim() === normalized); - if (existing) { - return existing.id; - } - } - // 2) Per-run throttle: avoid duplicate creations within a short window for identical type+name - const now = Date.now(); - const recent = lastCreationRef.current; - if (recent && recent.type === t && (recent.name ?? "") === normalized && now - recent.ts < 5000) { - return recent.id; - } - const id = addItem(t, name); - lastCreationRef.current = { type: t, name: normalized, id, ts: now }; - return id; - }, - }); - - // Frontend action: delete an item by id - useCopilotAction({ - name: "deleteItem", - description: "Delete an item by id.", - available: "remote", - parameters: [ - { name: "itemId", type: "string", required: true, description: "Target item id." }, - ], - handler: ({ itemId }: { itemId: string }) => { - const existed = (viewState.items ?? initialState.items).some((p) => p.id === itemId); - deleteItem(itemId); - return existed ? `deleted:${itemId}` : `not_found:${itemId}`; - }, - }); - - // Google Sheets Integration Actions - const [showSheetModal, setShowSheetModal] = useState(false); - const [isImporting, setIsImporting] = useState(false); - const [importError, setImportError] = useState(""); - const [availableSheets, setAvailableSheets] = useState([]); - const [selectedSheetName, setSelectedSheetName] = useState(""); - const [isCreatingSheet, setIsCreatingSheet] = useState(false); - const [newSheetTitle, setNewSheetTitle] = useState(""); - const [showFormatWarning, setShowFormatWarning] = useState(false); - const [formatWarningDetails, setFormatWarningDetails] = useState<{ - sheetId: string; - sheetName?: string; - existingFormat: string; - canvasFormat: string; - } | null>(null); - - const fetchAvailableSheets = async (sheetId: string) => { - try { - // Extract sheet ID from URL if needed - let cleanSheetId = sheetId.trim(); - if (cleanSheetId.includes("/spreadsheets/d/")) { - const start = cleanSheetId.indexOf("/spreadsheets/d/") + "/spreadsheets/d/".length; - const end = cleanSheetId.indexOf("/", start); - cleanSheetId = cleanSheetId.substring(start, end === -1 ? undefined : end); - } - - setImportError(""); - - // Make API call to list available sheets - const response = await fetch('/api/sheets/list', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ sheet_id: cleanSheetId }), - }); - - if (response.ok) { - const result = await response.json(); - const sheetNames = result.sheet_names || []; - setAvailableSheets(sheetNames); - if (sheetNames.length > 0) { - setSelectedSheetName(""); // Default to first sheet - } - } else { - const error = await response.json(); - setImportError(`Failed to list sheets: ${error.error}`); - } - - } catch (error) { - console.error('Error fetching sheets:', error); - setImportError("Failed to fetch available sheets"); - } - }; - - const importFromSheet = async (sheetId: string, sheetName?: string, forceImport = false) => { - if (!sheetId.trim()) { - setImportError("Please enter a valid Sheet ID"); - return; - } - - setIsImporting(true); - setImportError(""); - - try { - // Extract sheet ID from URL if needed - let cleanSheetId = sheetId.trim(); - if (cleanSheetId.includes("/spreadsheets/d/")) { - const start = cleanSheetId.indexOf("/spreadsheets/d/") + "/spreadsheets/d/".length; - const end = cleanSheetId.indexOf("/", start); - cleanSheetId = cleanSheetId.substring(start, end === -1 ? undefined : end); - } - - // Check for format compatibility if we have existing canvas data and not forcing import - if (!forceImport && viewState.items && viewState.items.length > 0) { - // Get a preview of what the sheet contains - try { - const previewResponse = await fetch('/api/sheets/import', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - sheet_id: cleanSheetId, - sheet_name: sheetName || selectedSheetName || undefined, - preview_only: true - }), - }); - - if (previewResponse.ok) { - const previewResult = await previewResponse.json(); - - // Check if the sheet has a different format than canvas - const hasCanvasFormat = viewState.items.some(item => - item.type && ['project', 'entity', 'note', 'chart'].includes(item.type) - ); - - const sheetHasData = previewResult.data && previewResult.data.items && previewResult.data.items.length > 0; - - if (hasCanvasFormat && sheetHasData) { - // Show format warning - setFormatWarningDetails({ - sheetId: cleanSheetId, - sheetName: sheetName || selectedSheetName || undefined, - existingFormat: `${previewResult.data.items.length} items detected in sheet`, - canvasFormat: `${viewState.items.length} items currently in canvas` - }); - setShowFormatWarning(true); - setIsImporting(false); - return; - } - } - } catch (previewError) { - console.warn("Could not preview sheet format:", previewError); - // Continue with normal import if preview fails - } - } - - // Make direct API call to backend for importing - const response = await fetch('/api/sheets/import', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - sheet_id: cleanSheetId, - sheet_name: sheetName || selectedSheetName || undefined - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.details || errorData.error || 'Failed to import sheet'); - } - - const result = await response.json(); - - if (result.success && result.data) { - // Update the canvas state with imported data - console.log("Import result data:", result.data); - setState(result.data); - setShowSheetModal(false); - setImportError(""); - console.log("Successfully imported sheet data:", result.message); - } else { - throw new Error(result.message || 'Failed to process sheet data'); - } - - } catch (error) { - console.error('Import error:', error); - setImportError(error instanceof Error ? error.message : 'Failed to import sheet'); - } finally { - setIsImporting(false); - } - }; - - const createNewSheet = async (title: string) => { - if (!title.trim()) { - setImportError("Please enter a valid sheet title"); - return; - } - - setIsCreatingSheet(true); - setImportError(""); - - try { - const response = await fetch('/api/sheets/create', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ title: title.trim() }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.details || errorData.error || 'Failed to create sheet'); - } - - const result = await response.json(); - console.log("Create sheet result:", result); - - if (result.success) { - const sheetId = result.sheet_id; - const sheetUrl = result.sheet_url; - - if (!sheetId) { - console.warn("Sheet creation succeeded but no sheet_id returned"); - setImportError("Sheet was created but ID not returned. Check your Google Drive."); - return; - } - - // If we have existing items, sync them to the new sheet first, then set up for bi-directional sync - if (viewState.items && viewState.items.length > 0) { - try { - const syncResponse = await fetch('/api/sheets/sync', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - canvas_state: viewState, - sheet_id: sheetId, - }), - }); - - if (syncResponse.ok) { - console.log("Successfully synced existing items to new sheet"); - // Set the newly created sheet as the sync target and update title/description - setState((prev) => ({ - ...prev, - items: prev?.items || [], - itemsCreated: prev?.itemsCreated || 0, - globalTitle: result.title || title.trim(), - globalDescription: `Connected to Google Sheet: ${result.title || title.trim()}`, - syncSheetId: sheetId, - syncSheetName: "Sheet1" - })); - } else { - console.warn("Failed to sync existing items to new sheet"); - } - } catch (syncError) { - console.warn("Failed to sync existing items to new sheet:", syncError); - } - } else { - // No existing items - import the empty sheet structure into canvas - try { - const importResponse = await fetch('/api/sheets/import', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - sheet_id: sheetId, - sheet_name: "Sheet1" - }), - }); - - if (importResponse.ok) { - const importResult = await importResponse.json(); - if (importResult.success && importResult.data) { - // Update canvas state with the imported (likely empty) structure and set title/description - setState({ - ...importResult.data, - globalTitle: result.title || title.trim(), - globalDescription: `Connected to Google Sheet: ${result.title || title.trim()}`, - syncSheetId: sheetId, - syncSheetName: "Sheet1" - }); - console.log("Successfully imported new sheet structure into canvas"); - } - } - } catch (importError) { - console.warn("Failed to import new sheet structure:", importError); - // Fallback: just set sync info and update title/description - setState((prev) => ({ - ...initialState, - ...prev, - globalTitle: result.title || title.trim(), - globalDescription: `Connected to Google Sheet: ${result.title || title.trim()}`, - syncSheetId: sheetId, - syncSheetName: "Sheet1" - })); - } - } - - setShowSheetModal(false); - setImportError(""); - console.log("Successfully created new sheet:", result.message); - - // Show success message or provide link - if (sheetUrl) { - window.open(sheetUrl, '_blank'); - } - - } else { - throw new Error('Failed to create sheet: ' + (result.error || result.message || 'Unknown error')); - } - - } catch (error) { - console.error('Create sheet error:', error); - setImportError(error instanceof Error ? error.message : 'Failed to create sheet'); - } finally { - setIsCreatingSheet(false); - } - }; - - useCopilotAction({ - name: "openSheetSelectionModal", - description: "Open modal for selecting Google Sheets.", - available: "remote", - parameters: [], - handler: () => { - setShowSheetModal(true); - return "sheet_modal_opened"; - }, - }); - - useCopilotAction({ - name: "setSyncSheetId", - description: "Set the Google Sheet ID for auto-sync.", - available: "remote", - parameters: [ - { name: "sheetId", type: "string", required: true, description: "The Google Sheet ID to sync with." }, - ], - handler: ({ sheetId }: { sheetId: string }) => { - setState((prev) => ({ - ...initialState, - ...prev, - syncSheetId: sheetId - })); - return `sync_sheet_set:${sheetId}`; - }, - }); - - useCopilotAction({ - name: "searchUserSheets", - description: "Search user's Google Sheets and display them for selection.", - available: "remote", - parameters: [], - handler: () => { - // This will be handled by the agent using GOOGLESHEETS_SEARCH_SPREADSHEETS - return "searching_sheets"; - }, - }); - - useCopilotAction({ - name: "syncCanvasToSheets", - description: "Manually sync current canvas state to Google Sheets.", - available: "remote", - parameters: [], - handler: async () => { - if (!viewState.syncSheetId) { - return "No sync sheet ID configured. Please set a sheet ID first."; - } - - if (!viewState.items || viewState.items.length === 0) { - return "No items to sync. Canvas is empty."; - } - - try { - console.log(`[MANUAL-SYNC] Syncing ${viewState.items.length} items to sheet: ${viewState.syncSheetId}`); - - const response = await fetch('/api/sheets/sync', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - canvas_state: viewState, - sheet_id: viewState.syncSheetId, - }), - }); - - if (response.ok) { - const result = await response.json(); - return `βœ… Successfully synced ${viewState.items.length} items to Google Sheets: ${result.message}`; - } else { - const error = await response.json(); - return `❌ Failed to sync to Google Sheets: ${error.error}`; - } - } catch (error) { - return `❌ Exception during manual sync: ${error}`; - } - }, - }); - - useCopilotAction({ - name: "forceCanvasToSheetsSync", - description: "Force sync current canvas state to a specific Google Sheet, even if syncSheetId is not set.", - available: "remote", - parameters: [ - { name: "sheetId", type: "string", required: true, description: "Google Sheet ID to sync to." }, - ], - handler: async ({ sheetId }: { sheetId: string }) => { - if (!viewState.items || viewState.items.length === 0) { - return "No items to sync. Canvas is empty."; - } - - try { - console.log(`[FORCE-SYNC] Syncing ${viewState.items.length} items to sheet: ${sheetId}`); - - const response = await fetch('/api/sheets/sync', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - canvas_state: viewState, - sheet_id: sheetId, - }), - }); - - if (response.ok) { - const result = await response.json(); - return `βœ… Successfully force-synced ${viewState.items.length} items to Google Sheets: ${result.message}`; - } else { - const error = await response.json(); - return `❌ Failed to force-sync to Google Sheets: ${error.error}`; - } - } catch (error) { - return `❌ Exception during force-sync: ${error}`; - } - }, - }); - - const titleClasses = cn( - /* base styles */ - "w-full outline-none rounded-md px-2 py-1", - "bg-transparent placeholder:text-gray-400", - "ring-1 ring-transparent transition-all ease-out", - /* hover styles */ - "hover:ring-border", - /* focus styles */ - "focus:ring-2 focus:ring-accent/50 focus:shadow-sm focus:bg-accent/10", - "focus:shadow-accent focus:placeholder:text-accent/65 focus:text-accent", - ); - - const sheetModalInputClasses = cn( - "w-full rounded-xl border border-border/80 bg-background px-3.5 py-2.5 text-sm text-foreground shadow-xs transition-all", - "placeholder:text-muted-foreground/70 focus:border-accent focus:ring-2 focus:ring-accent/30 focus:outline-none", - "disabled:cursor-not-allowed disabled:opacity-60" - ); - - const handleCloseSheetsModal = useCallback(() => { - if (isImporting || isCreatingSheet) { - return; - } - setShowSheetModal(false); - setImportError(""); - setAvailableSheets([]); - setSelectedSheetName(""); - setNewSheetTitle(""); - }, [ - isCreatingSheet, - isImporting, - setAvailableSheets, - setImportError, - setNewSheetTitle, - setSelectedSheetName, - setShowSheetModal, - ]); - - const [sheetId, setSheetId] = useState('') + // Redirect to the companies page + router.push("/companies"); + }, [router]); return ( -
- {/* Main Layout */} -
- {/* Chat Sidebar */} - - {/* Main Content */} -
-
-
- {/* Global Title & Description (hidden in JSON view) */} - {!showJsonView && ( - - ) => - setState((prev) => ({ ...(prev ?? initialState), globalTitle: e.target.value })) - } - placeholder="Canvas title..." - className={cn(titleClasses, "text-2xl font-semibold")} - /> - ) => - setState((prev) => ({ ...(prev ?? initialState), globalDescription: e.target.value })) - } - placeholder="Canvas description..." - className={cn(titleClasses, "mt-2 text-sm leading-6 resize-none overflow-hidden")} - /> - - )} - - {(viewState.items ?? []).length === 0 ? ( - -
-

Nothing here yet

-

Create your first item to get started.

-
- addItem(t)} align="center" className="md:h-10" /> -
-
-
- ) : ( -
- {showJsonView ? ( -
-
- - {JSON.stringify(getStatePreviewJSON(viewState), null, 2)} - -
-
- ) : ( -
- {(viewState.items ?? []).map((item) => ( -
- - updateItem(item.id, { name: v })} - onSubtitleChange={(v) => updateItem(item.id, { subtitle: v })} - /> - -
- updateItemData(item.id, updater)} onToggleTag={(tag) => toggleTag(item.id, tag)} /> -
-
- ))} -
- )} -
- )} -
-
- {(viewState.items ?? []).length > 0 ? ( -
- addItem(t)} - align="center" - className="rounded-r-none border-r-0 peer" - /> - - -
- ) : null} -
-
-
- {/* Mobile Chat Popup - conditionally rendered to avoid duplicate rendering */} - {!isDesktop && ( - - )} +
+
+

Welcome to Pitch Platform

+

Redirecting to companies...

- - {/* Google Sheets Selection Modal */} - {showSheetModal && ( -
-
event.stopPropagation()} - > -
-
-

Connections

-

Google Sheets

-

Sync the canvas with a Sheetβ€”create a new one or import an existing source.

-
- -
- -
-
-
-
- - Sync starts immediately -
-
- setNewSheetTitle(e.target.value)} - disabled={isImporting || isCreatingSheet} - onKeyDown={(e) => { - if (e.key === "Enter" && newSheetTitle.trim()) { - createNewSheet(newSheetTitle); - } - }} - /> - -
-
- -
-
- Import an existing Sheet -

Paste a Sheet link or ID. We’ll hydrate the canvas and keep the connection live.

-
- - {importError && ( -
- {importError} -
- )} - -
- ) => setSheetId(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - const input = e.target as HTMLInputElement; - importFromSheet(input.value); - } - }} - /> - -
- - -
- -
- - -
-
-
- -
-

Heads up: New Sheets open in a new tab. If you import, ensure Composio has access to the document.

-

We automatically map rows into projects, entities, notes, or charts so your canvas stays structured.

-
- -
- - {viewState.syncSheetId && ( - - Currently linked to {viewState.syncSheetName || "Sheet1"} - - )} -
-
-
-
-
- )} - - {/* Format Warning Modal */} - {showFormatWarning && formatWarningDetails && ( -
-
-
-
-

Import check

-

Format mismatch

-

Replacing your canvas will overwrite existing cards with what’s in the Sheet.

-
- -
- -
-
-

Sheet details

-
-

Sheet: {formatWarningDetails.existingFormat}

-

Canvas: {formatWarningDetails.canvasFormat}

-
-
- -

- Importing will completely replace your current canvas data with the sheet contents. Your existing cards will be lost unless they’re saved elsewhere. -

- -
- - -
- -
-

Tip

-

Consider creating a new Sheet or exporting your canvas JSON before importing if you might need to roll back.

-
-
-
-
- )}
); } diff --git a/src/app/pitch-score/page.tsx b/src/app/pitch-score/page.tsx new file mode 100644 index 0000000..022a0db --- /dev/null +++ b/src/app/pitch-score/page.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import Link from "next/link"; +import { ArrowLeft, Download, Send } from "lucide-react"; +import { useCopilotAction } from "@copilotkit/react-core"; + +interface AssessmentCriteria { + id: string; + category: string; + criteria: string; + description: string; + score: number; + weight: number; +} + +interface PitchAssessment { + sellerId: string; + sellerName: string; + companyId: string; + companyName: string; + date: string; + overallScore: number; + criteria: AssessmentCriteria[]; + feedback: string; + recommendation: "highly_recommend" | "recommend" | "neutral" | "not_recommend"; +} + +export default function PitchScorePage() { + const searchParams = useSearchParams(); + const companyId = searchParams.get("company") || "1"; + + const [assessment, setAssessment] = useState({ + sellerId: "seller-1", + sellerName: "John Smith", + companyId, + companyName: "TechCorp Solutions", + date: new Date().toISOString().split("T")[0], + overallScore: 0, + criteria: [ + { + id: "1", + category: "Product Knowledge", + criteria: "Understanding of Product/Service", + description: "Demonstrates deep knowledge of features, benefits, and use cases", + score: 0, + weight: 20, + }, + { + id: "2", + category: "Communication", + criteria: "Clarity and Articulation", + description: "Communicates ideas clearly and adapts message to audience", + score: 0, + weight: 15, + }, + { + id: "3", + category: "Needs Analysis", + criteria: "Understanding Customer Needs", + description: "Asks relevant questions and identifies pain points accurately", + score: 0, + weight: 20, + }, + { + id: "4", + category: "Solution Fit", + criteria: "Relevance of Proposed Solution", + description: "Aligns solution with specific company needs and challenges", + score: 0, + weight: 20, + }, + { + id: "5", + category: "Objection Handling", + criteria: "Addressing Concerns", + description: "Handles objections professionally and provides satisfactory answers", + score: 0, + weight: 15, + }, + { + id: "6", + category: "Professionalism", + criteria: "Overall Professionalism", + description: "Maintains professional demeanor, punctuality, and follow-up", + score: 0, + weight: 10, + }, + ], + feedback: "", + recommendation: "neutral", + }); + + // Calculate overall score + const calculateOverallScore = () => { + const totalScore = assessment.criteria.reduce((sum, criterion) => { + return sum + (criterion.score * criterion.weight) / 100; + }, 0); + return Math.round(totalScore); + }; + + // Update criterion score + const updateScore = (criterionId: string, score: number) => { + setAssessment(prev => ({ + ...prev, + criteria: prev.criteria.map(c => + c.id === criterionId ? { ...c, score } : c + ), + overallScore: calculateOverallScore(), + })); + }; + + // CopilotKit action to help with assessment + useCopilotAction({ + name: "suggest_feedback", + description: "Suggest constructive feedback based on the scores", + parameters: [ + { + name: "scores", + type: "object", + description: "The current assessment scores", + required: true, + }, + ], + handler: async ({ scores }) => { + // Generate feedback based on scores + const feedback = "Based on the scores, the seller showed strong product knowledge but could improve on needs analysis..."; + setAssessment(prev => ({ ...prev, feedback })); + return "Feedback suggestion added"; + }, + }); + + const getRecommendationColor = (recommendation: PitchAssessment["recommendation"]) => { + switch (recommendation) { + case "highly_recommend": + return "text-green-600"; + case "recommend": + return "text-blue-600"; + case "neutral": + return "text-yellow-600"; + case "not_recommend": + return "text-red-600"; + } + }; + + const overallScore = calculateOverallScore(); + + return ( +
+
+
+ + + + +
+
+

Seller's Pitch Score

+

+ Evaluate {assessment.sellerName}'s pitch to {assessment.companyName} +

+
+
+
{overallScore}%
+

Overall Score

+
+
+
+ + {/* Assessment Criteria */} +
+ {assessment.criteria.map((criterion) => ( +
+
+
+

{criterion.criteria}

+ + Weight: {criterion.weight}% + +
+

{criterion.description}

+
+ +
+
+ Score + {criterion.score}/10 +
+
+ {[...Array(10)].map((_, i) => ( + + ))} +
+
+
+ ))} +
+ + {/* Overall Assessment */} +
+

Overall Assessment

+ +
+
+ Total Score + {overallScore}% +
+ +
+ +
+
+ + +
+ +
+ +