diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 2754ae14..d47874a2 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -28,3 +28,4 @@ export { aiSessionsApi } from './aiSessions' export { flowProposalsApi } from './flowProposals' export { flowpilotAnalyticsApi } from './flowpilotAnalytics' export { notificationsApi } from './notifications' +export { publicTemplatesApi } from './publicTemplates' diff --git a/frontend/src/api/publicTemplates.ts b/frontend/src/api/publicTemplates.ts new file mode 100644 index 00000000..e3f20d55 --- /dev/null +++ b/frontend/src/api/publicTemplates.ts @@ -0,0 +1,54 @@ +import type { + PublicGalleryResponse, + PublicFlowDetail, + PublicScriptDetail, + GalleryCategory, +} from '@/types/public-templates' + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + +export const publicTemplatesApi = { + async listGallery(params?: { + category?: string + type?: string + sort?: string + page?: number + per_page?: number + }): Promise { + const searchParams = new URLSearchParams() + if (params?.category) searchParams.set('category', params.category) + if (params?.type) searchParams.set('type', params.type) + if (params?.sort) searchParams.set('sort', params.sort) + if (params?.page) searchParams.set('page', String(params.page)) + if (params?.per_page) searchParams.set('per_page', String(params.per_page)) + const qs = searchParams.toString() + const response = await fetch(`${API_URL}/api/v1/public/templates${qs ? `?${qs}` : ''}`) + if (!response.ok) throw new Error('Failed to load gallery') + return response.json() + }, + + async getFlowDetail(id: string): Promise { + const response = await fetch(`${API_URL}/api/v1/public/templates/flows/${id}`) + if (!response.ok) throw new Error('Failed to load flow detail') + return response.json() + }, + + async getScriptDetail(id: string): Promise { + const response = await fetch(`${API_URL}/api/v1/public/templates/scripts/${id}`) + if (!response.ok) throw new Error('Failed to load script detail') + return response.json() + }, + + async listCategories(): Promise { + const response = await fetch(`${API_URL}/api/v1/public/templates/categories`) + if (!response.ok) throw new Error('Failed to load categories') + return response.json() + }, + + async search(q: string): Promise { + const searchParams = new URLSearchParams({ q }) + const response = await fetch(`${API_URL}/api/v1/public/templates/search?${searchParams}`) + if (!response.ok) throw new Error('Search failed') + return response.json() + }, +} diff --git a/frontend/src/components/public/FlowTemplateCard.tsx b/frontend/src/components/public/FlowTemplateCard.tsx new file mode 100644 index 00000000..b1631f31 --- /dev/null +++ b/frontend/src/components/public/FlowTemplateCard.tsx @@ -0,0 +1,96 @@ +import { GitBranch, BarChart3, Layers } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { PublicFlowTemplate } from '@/types' + +interface FlowTemplateCardProps { + template: PublicFlowTemplate + onClick: () => void +} + +const typeLabels: Record = { + troubleshooting: 'Troubleshooting', + procedural: 'Project', + maintenance: 'Maintenance', +} + +export function FlowTemplateCard({ template, onClick }: FlowTemplateCardProps) { + return ( + + ) +} diff --git a/frontend/src/components/public/ScriptTemplateCard.tsx b/frontend/src/components/public/ScriptTemplateCard.tsx new file mode 100644 index 00000000..9fb69a16 --- /dev/null +++ b/frontend/src/components/public/ScriptTemplateCard.tsx @@ -0,0 +1,101 @@ +import { Terminal, ShieldCheck, CheckCircle2, Package } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { PublicScriptTemplate } from '@/types' + +interface ScriptTemplateCardProps { + template: PublicScriptTemplate + onClick: () => void +} + +const complexityColors: Record = { + basic: 'text-emerald-400 border-emerald-400/20 bg-emerald-400/5', + intermediate: 'text-amber-400 border-amber-400/20 bg-amber-400/5', + advanced: 'text-rose-500 border-rose-500/20 bg-rose-500/5', +} + +export function ScriptTemplateCard({ template, onClick }: ScriptTemplateCardProps) { + return ( + + ) +} diff --git a/frontend/src/components/public/TemplateDetailModal.tsx b/frontend/src/components/public/TemplateDetailModal.tsx new file mode 100644 index 00000000..64dea909 --- /dev/null +++ b/frontend/src/components/public/TemplateDetailModal.tsx @@ -0,0 +1,314 @@ +import { useEffect } from 'react' +import { Link } from 'react-router-dom' +import { + X, + GitBranch, + Terminal, + Layers, + BarChart3, + ShieldCheck, + CheckCircle2, + Package, + ChevronRight, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import type { PublicFlowDetail, PublicScriptDetail } from '@/types' + +type TemplateDetailModalProps = + | { + type: 'flow' + template: PublicFlowDetail + onClose: () => void + } + | { + type: 'script' + template: PublicScriptDetail + onClose: () => void + } + +const complexityColors: Record = { + basic: 'text-emerald-400', + intermediate: 'text-amber-400', + advanced: 'text-rose-500', +} + +export function TemplateDetailModal(props: TemplateDetailModalProps) { + const { type, template, onClose } = props + + // Close on Escape + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [onClose]) + + // Prevent body scroll while modal is open + useEffect(() => { + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = '' + } + }, []) + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
e.stopPropagation()} + > + {/* Header */} +
+
+ {type === 'flow' ? ( + + ) : ( + + )} +
+

+ {template.name} + {type === 'script' && (template as PublicScriptDetail).is_verified && ( + + )} +

+

+ {type === 'flow' ? 'Flow Template' : 'Script Template'} +

+
+
+ +
+ + {/* Body */} +
+ {template.description && ( +

+ {template.description} +

+ )} + + {/* Metadata */} +
+ {type === 'flow' && ( + <> + } + /> + {(template as PublicFlowDetail).success_rate !== null && ( + } + valueClassName={ + (template as PublicFlowDetail).success_rate! >= 80 + ? 'text-emerald-400' + : (template as PublicFlowDetail).success_rate! >= 50 + ? 'text-amber-400' + : 'text-rose-500' + } + /> + )} + + + )} + {type === 'script' && ( + <> + {(template as PublicScriptDetail).complexity && ( + + )} + + {(template as PublicScriptDetail).requires_elevation && ( + } + /> + )} + + )} +
+ + {/* Tags */} + {template.tags.length > 0 && ( +
+

+ Tags +

+
+ {template.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {/* Flow preview structure */} + {type === 'flow' && (template as PublicFlowDetail).preview_structure && ( +
+

+ Flow Preview +

+
+ +
+
+ )} + + {/* Script parameters */} + {type === 'script' && (template as PublicScriptDetail).parameters.length > 0 && ( +
+

+ Parameters +

+
+ {(template as PublicScriptDetail).parameters.map((param) => ( +
+ + {param.name} + + + {param.description} + + + {param.type} + +
+ ))} +
+
+ )} + + {/* Script modules */} + {type === 'script' && (template as PublicScriptDetail).requires_modules.length > 0 && ( +
+

+ Required Modules +

+
+ {(template as PublicScriptDetail).requires_modules.map((mod) => ( + + + {mod} + + ))} +
+
+ )} +
+ + {/* Footer */} +
+ + + Sign Up to Use This + + +
+
+
+ ) +} + +function MetaStat({ + label, + value, + icon, + valueClassName, +}: { + label: string + value: string + icon?: React.ReactNode + valueClassName?: string +}) { + return ( +
+
+ {label} +
+
+ {icon} + {value} +
+
+ ) +} + +/** + * Renders a simplified nested list from the preview_structure object. + * Expected shape: { name: string, children?: Array<...> } + */ +function PreviewTree({ structure }: { structure: Record }) { + const name = (structure.name as string) || (structure.title as string) || 'Root' + const children = (structure.children as Array>) || [] + + return ( +
+
+ + {name} +
+ {children.length > 0 && ( +
+ {children.slice(0, 8).map((child, i) => ( + + ))} + {children.length > 8 && ( + + +{children.length - 8} more steps... + + )} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/PublicTemplatesPage.tsx b/frontend/src/pages/PublicTemplatesPage.tsx new file mode 100644 index 00000000..10043bfe --- /dev/null +++ b/frontend/src/pages/PublicTemplatesPage.tsx @@ -0,0 +1,456 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { Search, SlidersHorizontal, ChevronDown, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { PageMeta } from '@/components/common/PageMeta' +import { BrandLogo } from '@/components/common/BrandLogo' +import { FlowTemplateCard } from '@/components/public/FlowTemplateCard' +import { ScriptTemplateCard } from '@/components/public/ScriptTemplateCard' +import { TemplateDetailModal } from '@/components/public/TemplateDetailModal' +import { publicTemplatesApi } from '@/api/publicTemplates' +import type { + PublicFlowTemplate, + PublicScriptTemplate, + PublicFlowDetail, + PublicScriptDetail, + PublicGalleryResponse, +} from '@/types' + +type SortOption = 'most_used' | 'newest' | 'highest_success' +type TypeFilter = 'all' | 'flows' | 'scripts' + +const sortLabels: Record = { + most_used: 'Most Used', + newest: 'Newest', + highest_success: 'Highest Success Rate', +} + +export default function PublicTemplatesPage() { + const [searchParams, setSearchParams] = useSearchParams() + + // State + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Filters + const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '') + const [activeCategory, setActiveCategory] = useState(searchParams.get('category') || '') + const [typeFilter, setTypeFilter] = useState( + (searchParams.get('type') as TypeFilter) || 'all' + ) + const [sort, setSort] = useState( + (searchParams.get('sort') as SortOption) || 'most_used' + ) + const [sortOpen, setSortOpen] = useState(false) + + // Detail modal + const [selectedFlow, setSelectedFlow] = useState(null) + const [selectedScript, setSelectedScript] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + + const searchTimeout = useRef | null>(null) + + // Fetch gallery data + const fetchGallery = useCallback(async () => { + setLoading(true) + setError(null) + try { + const result = await publicTemplatesApi.listGallery({ + category: activeCategory || undefined, + type: typeFilter === 'all' ? undefined : typeFilter, + sort, + }) + setData(result) + } catch { + setError('Failed to load templates. Please try again.') + } finally { + setLoading(false) + } + }, [activeCategory, typeFilter, sort]) + + // Search + const doSearch = useCallback(async (q: string) => { + if (!q.trim()) { + fetchGallery() + return + } + setLoading(true) + setError(null) + try { + const result = await publicTemplatesApi.search(q.trim()) + setData(result) + } catch { + setError('Search failed. Please try again.') + } finally { + setLoading(false) + } + }, [fetchGallery]) + + // Initial load & filter changes + useEffect(() => { + if (!searchQuery.trim()) { + fetchGallery() + } + }, [fetchGallery, searchQuery]) + + // Sync filters to URL + useEffect(() => { + const params = new URLSearchParams() + if (searchQuery) params.set('q', searchQuery) + if (activeCategory) params.set('category', activeCategory) + if (typeFilter !== 'all') params.set('type', typeFilter) + if (sort !== 'most_used') params.set('sort', sort) + setSearchParams(params, { replace: true }) + }, [searchQuery, activeCategory, typeFilter, sort, setSearchParams]) + + // Debounced search + const handleSearchChange = (value: string) => { + setSearchQuery(value) + if (searchTimeout.current) clearTimeout(searchTimeout.current) + searchTimeout.current = setTimeout(() => { + doSearch(value) + }, 400) + } + + // Open detail modal + const openFlowDetail = async (template: PublicFlowTemplate) => { + setDetailLoading(true) + try { + const detail = await publicTemplatesApi.getFlowDetail(template.id) + setSelectedFlow(detail) + } catch { + // Fallback to the list data + setSelectedFlow(template) + } finally { + setDetailLoading(false) + } + } + + const openScriptDetail = async (template: PublicScriptTemplate) => { + setDetailLoading(true) + try { + const detail = await publicTemplatesApi.getScriptDetail(template.id) + setSelectedScript(detail) + } catch { + // Script list items don't have full detail — show what we have + setSelectedScript({ + ...template, + parameters: [], + }) + } finally { + setDetailLoading(false) + } + } + + const categories = data?.categories || [] + const flows = data?.flow_templates || [] + const scripts = data?.script_templates || [] + const showFlows = typeFilter === 'all' || typeFilter === 'flows' + const showScripts = typeFilter === 'all' || typeFilter === 'scripts' + + return ( + <> + + +
+ {/* Ambient orbs */} +
+
+ + {/* Header */} +
+
+ + + + Resolution + Flow + + +
+ + Sign In + + + Sign Up Free + +
+
+
+ + {/* Hero */} +
+
+

+ MSP Troubleshooting{' '} + Templates +

+

+ Battle-tested flows and scripts built by MSP engineers. Free to browse, powerful when connected to FlowPilot. +

+ + {/* Search */} +
+ + handleSearchChange(e.target.value)} + className="w-full pl-12 pr-4 py-3.5 rounded-2xl text-sm bg-card border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors" + /> +
+
+
+ + {/* Filters */} +
+
+ {/* Category pills */} + {categories.length > 0 && ( +
+ + {categories.map((cat) => ( + + ))} +
+ )} + + {/* Type toggle + Sort */} +
+ {/* Type segmented control */} +
+ {(['all', 'flows', 'scripts'] as TypeFilter[]).map((t) => ( + + ))} +
+ + {/* Sort dropdown */} +
+ + {sortOpen && ( + <> +
setSortOpen(false)} /> +
+ {(Object.entries(sortLabels) as [SortOption, string][]).map(([key, label]) => ( + + ))} +
+ + )} +
+
+
+
+ + {/* Content */} +
+
+ {/* Loading */} + {loading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+ )} + + {/* Error */} + {!loading && error && ( +
+

{error}

+ +
+ )} + + {/* Empty */} + {!loading && !error && flows.length === 0 && scripts.length === 0 && ( +
+

+ No templates found. Try a different search or category. +

+
+ )} + + {/* Results */} + {!loading && !error && (flows.length > 0 || scripts.length > 0) && ( +
+ {/* Flows */} + {showFlows && flows.length > 0 && ( +
+ {typeFilter === 'all' && ( +

+ Flows ({data?.total_flows || flows.length}) +

+ )} +
+ {flows.map((flow) => ( + openFlowDetail(flow)} + /> + ))} +
+
+ )} + + {/* Scripts */} + {showScripts && scripts.length > 0 && ( +
+ {typeFilter === 'all' && ( +

+ Scripts ({data?.total_scripts || scripts.length}) +

+ )} +
+ {scripts.map((script) => ( + openScriptDetail(script)} + /> + ))} +
+
+ )} +
+ )} +
+
+ + {/* Footer */} +
+
+ + Powered by ResolutionFlow + +
+ + Get Started + +
+
+
+ + {/* Detail loading overlay */} + {detailLoading && ( +
+ +
+ )} + + {/* Detail modals */} + {selectedFlow && ( + setSelectedFlow(null)} + /> + )} + {selectedScript && ( + setSelectedScript(null)} + /> + )} +
+ + ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 71acec9a..ba25eef2 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -14,6 +14,7 @@ import { // Public pages const LandingPage = lazy(() => import('@/pages/LandingPage')) +const PublicTemplatesPage = lazy(() => import('@/pages/PublicTemplatesPage')) const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage')) const SurveyPage = lazy(() => import('@/pages/SurveyPage')) const SurveyThankYouPage = lazy(() => import('@/pages/SurveyThankYouPage')) @@ -92,6 +93,11 @@ export const router = sentryCreateBrowserRouter([ element: page(LandingPage), errorElement: , }, + { + path: '/templates', + element: page(PublicTemplatesPage), + errorElement: , + }, { path: '/login', element: , diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0f0f8da3..31fdda1e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -94,3 +94,4 @@ export type { export * from './scripts' export * from './integrations' export * from './notification' +export type * from './public-templates' diff --git a/frontend/src/types/public-templates.ts b/frontend/src/types/public-templates.ts new file mode 100644 index 00000000..416866a4 --- /dev/null +++ b/frontend/src/types/public-templates.ts @@ -0,0 +1,60 @@ +export interface PublicFlowTemplate { + id: string + name: string + description: string | null + category: string | null + tree_type: string + step_count: number + usage_count: number + success_rate: number | null + tags: string[] + preview_structure: Record | null + created_at: string +} + +export interface PublicScriptTemplate { + id: string + name: string + description: string | null + category_name: string | null + category_icon: string | null + complexity: string | null + tags: string[] + parameter_count: number + requires_elevation: boolean + requires_modules: string[] + usage_count: number + is_verified: boolean + created_at: string +} + +export interface PublicGalleryResponse { + flow_templates: PublicFlowTemplate[] + script_templates: PublicScriptTemplate[] + total_flows: number + total_scripts: number + categories: string[] + domains: string[] +} + +export interface PublicFlowDetail extends PublicFlowTemplate {} + +export interface PublicScriptDetail { + id: string + name: string + description: string | null + category_name: string | null + complexity: string | null + tags: string[] + parameters: Array<{ name: string; description: string; type: string }> + requires_elevation: boolean + requires_modules: string[] + usage_count: number + is_verified: boolean + created_at: string +} + +export interface GalleryCategory { + name: string + count: number +}