diff --git a/apps/apollo-vertex/app/experiment/_meta.ts b/apps/apollo-vertex/app/experiment/_meta.ts new file mode 100644 index 000000000..c1c9a9df9 --- /dev/null +++ b/apps/apollo-vertex/app/experiment/_meta.ts @@ -0,0 +1,3 @@ +export default { + "modular-dashboard": "Modular Dashboard", +}; diff --git a/apps/apollo-vertex/app/experiment/modular-dashboard/page.mdx b/apps/apollo-vertex/app/experiment/modular-dashboard/page.mdx new file mode 100644 index 000000000..73bf6b217 --- /dev/null +++ b/apps/apollo-vertex/app/experiment/modular-dashboard/page.mdx @@ -0,0 +1,149 @@ +import { DashboardTemplate } from '@/templates/dashboard/DashboardTemplateDynamic'; +import { PreviewFullScreen } from '@/app/_components/preview-full-screen'; + +# Dashboard + +A configurable dashboard template for building AI-assisted operational views. The dashboard is composed of named card regions with defined interaction patterns, designed to be data-driven and adaptable across verticals. + +## Previews + +### Sidebar Shell + + + + + +Open standalone preview → + +### Minimal Header Shell + + + + + +Open standalone preview → + +--- + +## Anatomy + +The dashboard is built from four semantic regions, each with a distinct role: + +| Region | Description | +|---|---| +| **Overview Card** | The primary narrative area. Displays a greeting, headline, and subhead that summarize the current state. Occupies the top-left of the layout. | +| **Prompt Bar** | The AI input surface. Supports text entry, recommendation chips, and an expand interaction that opens an inline chat view. Anchored below the overview card. | +| **Insight Cards** | A grid of data cards on the right side. Each card has a type, a chart visualization, and an interaction pattern. Cards are arranged in rows of two. | +| **Autopilot Panel** | A slide-in panel that provides AI-generated analysis for a specific insight card. Triggered from the card's autopilot icon. | + +## Card Types + +Every insight card has a `type` that determines what it displays: + +| Type | Renders | Example | +|---|---|---| +| `kpi` | A large number, badge, and description | "97.1%" with "+2.4%" badge | +| `chart` | A data visualization determined by `chartType` | Horizontal bars, stacked bars | + +## Chart Types + +Cards with `type: "chart"` use a `chartType` to select the visualization: + +| Chart Type | Description | +|---|---| +| `horizontal-bars` | Ranked list of items with proportional bars and percentages | +| `stacked-bar` | Grouped bars with color-coded segments and a legend | +| `donut` | Circular progress indicator with a center label | +| `sparkline` | Compact line chart for trend indication | +| `area` | Filled area chart for volume over time | + +## Card Sizes + +Each card has a `size` that controls its column weight in the grid: + +| Size | Grid Weight | Use Case | +|---|---|---| +| `sm` | `1fr` | KPI cards, compact metrics | +| `md` | `2fr` | Chart cards, detailed visualizations | +| `lg` | `1fr` | Full-width cards | + +## Interaction Patterns + +Cards support three interaction modes: + +### Static + +No interactive behavior. The card displays its content without any hover or click affordance. Used for simple KPIs that don't need drill-down. + +### Navigate + +On hover, an **arrow-up-right** icon appears in the card header. Clicking the card navigates to a detail page. Used for KPIs or summaries that link to a deeper view. + +### Expand + +On hover, a **maximize** icon appears in the card header. Clicking expands the card to fill the grid with a multi-phase animation: + +1. **Width phase** — Card expands horizontally, sibling card collapses +2. **Height phase** — Card expands vertically, other rows collapse +3. **Full phase** — Expanded content fades in (drilldown tabs, autopilot prompts) + +Clicking the **minimize** icon or another card collapses back to the grid. + +For `horizontal-bars` cards, the expanded state includes **drilldown tabs** below the title for switching between data views. + +## Prompt Bar + +The prompt bar supports three ways to expand into the inline chat view: + +| Trigger | Behavior | +|---|---| +| **Type + Enter / Submit** | Expands and passes the user's typed query | +| **Click a recommendation chip** | Expands and passes the chip text as the query | +| **Click the chat icon** | Expands and shows the current session conversation | + +When expanded, the overview card collapses and the prompt bar grows to fill the left column. A **minimize** icon in the header collapses back to the default layout. + +## Autopilot Panel + +Each expandable card includes an **autopilot** icon alongside the expand icon. Clicking it slides in a panel from the right that provides AI-generated context for that card. The dashboard content shifts left to make room. Clicking the autopilot icon again or the close button dismisses the panel. + +## Data Configuration + +The dashboard is driven by a `DashboardDataset` object that defines all text and card content: + +```ts +interface DashboardDataset { + name: string; // Dataset identifier + brandName: string; // Company name in header + brandLine: string; // Tagline in header + dashboardTitle: string; // Page title + badgeText: string; // Badge next to title + greeting: string; // Overview card greeting + headline: string; // Overview card headline + subhead: string; // Overview card description + promptPlaceholder: string; // Prompt bar placeholder text + promptSuggestions: string[]; // Recommendation chips + insightCards: [ // Exactly 4 insight cards + InsightCardData, + InsightCardData, + InsightCardData, + InsightCardData, + ]; +} +``` + +Each `InsightCardData` specifies its title, type, chart type, size, interaction, and the data for its visualization (KPI values, bar data, stacked segments, etc.). + +## Layout Modes + +The dashboard responds to container width: + +| Mode | Breakpoint | Behavior | +|---|---|---| +| **Desktop** | ≥ 1100px | Two-column layout, full card grid | +| **Compact** | 800–1099px | Two-column layout, condensed card content | +| **Stacked** | < 800px | Single-column, vertically stacked | + +## Theming + +Card backgrounds, glow effects, and gradients are configurable through `CardConfig` and `GlowConfig` objects. The dashboard supports light and dark modes with independent styling for each. Color tokens and theme values should be updated in `registry.json`, not directly in CSS files. diff --git a/apps/apollo-vertex/app/layout.tsx b/apps/apollo-vertex/app/layout.tsx index 9cd402514..3b71024df 100644 --- a/apps/apollo-vertex/app/layout.tsx +++ b/apps/apollo-vertex/app/layout.tsx @@ -1,5 +1,6 @@ import { Inter } from "next/font/google"; import Image from "next/image"; +import { headers } from "next/headers"; import { Head } from "nextra/components"; import { getPageMap } from "nextra/page-map"; import { Footer, Layout, Navbar } from "nextra-theme-docs"; @@ -76,6 +77,10 @@ export default async function RootLayout({ }: { children: ReactNode; }) { + const h = await headers(); + const pathname = h.get("x-pathname") ?? ""; + const isPreview = pathname.startsWith("/preview/"); + return ( - - {children} - + {isPreview ? ( + children + ) : ( + + {children} + + )} diff --git a/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx b/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx index 30acb999a..deba5cb66 100644 --- a/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx +++ b/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx @@ -3,7 +3,10 @@ import dynamic from "next/dynamic"; const DashboardTemplate = dynamic( - () => import("@/templates/dashboard/DashboardTemplate").then((mod) => mod.DashboardTemplate), + () => + import("@/templates/dashboard/DashboardTemplate").then( + (mod) => mod.DashboardTemplate, + ), { ssr: false }, ); diff --git a/apps/apollo-vertex/app/preview/dashboard/page.tsx b/apps/apollo-vertex/app/preview/dashboard/page.tsx index db71ea817..018db5cc2 100644 --- a/apps/apollo-vertex/app/preview/dashboard/page.tsx +++ b/apps/apollo-vertex/app/preview/dashboard/page.tsx @@ -3,7 +3,10 @@ import dynamic from "next/dynamic"; const DashboardTemplate = dynamic( - () => import("@/templates/dashboard/DashboardTemplate").then((mod) => mod.DashboardTemplate), + () => + import("@/templates/dashboard/DashboardTemplate").then( + (mod) => mod.DashboardTemplate, + ), { ssr: false }, ); diff --git a/apps/apollo-vertex/middleware.ts b/apps/apollo-vertex/middleware.ts new file mode 100644 index 000000000..a4a8315e7 --- /dev/null +++ b/apps/apollo-vertex/middleware.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function middleware(request: NextRequest) { + const headers = new Headers(request.headers); + headers.set("x-pathname", request.nextUrl.pathname); + return NextResponse.next({ request: { headers } }); +} + +export const config = { + matcher: ["/((?!_next|api|.*\\..*).*)"], +}; diff --git a/apps/apollo-vertex/next-env.d.ts b/apps/apollo-vertex/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/apollo-vertex/next-env.d.ts +++ b/apps/apollo-vertex/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx b/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx index 4f98b2323..f9df8aa64 100644 --- a/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx +++ b/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx @@ -41,16 +41,16 @@ export function AutopilotInsight({
Autopilot Autopilot - Autopilot Insight + AI Assistant

@@ -63,7 +63,7 @@ export function AutopilotInsight({

- Autopilot response area + AI Assistant response area

Chat UX content will appear here diff --git a/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx b/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx index 70dd812bd..63d326b5a 100644 --- a/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx +++ b/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx @@ -1,6 +1,292 @@ "use client"; -// Stub — full implementation is added in a later PR once all dependencies are available. +import { useEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AutopilotInsight } from "./AutopilotInsight"; +import { DashboardDataProvider } from "./DashboardDataProvider"; +import { DashboardGlow } from "./DashboardGlow"; +import { useDashboardData } from "./dashboard-data-context"; +import { + type CardConfig, + cardBgStyle, + defaultDarkCards, + defaultDarkGlow, + defaultLayout, + type GlowConfig, + type LayoutConfig, +} from "./glow-config"; +import { InsightGrid } from "./InsightGrid"; +import { PromptBar } from "./PromptBar"; + +type LayoutType = "executive" | "operational" | "analytics"; + +function ExecutiveLayout({ + cards, + layout, + viewMode, + onAutopilotOpen, + autopilotActiveIdx, +}: { + cards: CardConfig; + layout: LayoutConfig; + viewMode: ViewMode; + onAutopilotOpen?: (sourceTitle: string, idx: number) => void; + autopilotActiveIdx?: number | null; +}) { + const { data } = useDashboardData(); + const [promptExpanded, setPromptExpanded] = useState(false); + const borderClass = cards.borderVisible ? "" : "dark:!border-transparent"; + const blurClass = cards.backdropBlur ? "" : "dark:!backdrop-blur-none"; + const shared = `!shadow-none dark:![background:var(--card-bg-override)] ${borderClass} ${blurClass}`; + const gapStyle = { gap: `${layout.gap}px` }; + + return ( +

+
+
+ + + AI Assistant + AI Assistant + + {data.greeting} + + + +
+

+ {data.headline} +

+

+ {data.subhead} +

+
+
+
+
+ setPromptExpanded(true)} + onExpand={() => setPromptExpanded(true)} + onCollapse={() => setPromptExpanded(false)} + /> +
+
+ +
+
+ ); +} + +function OperationalLayout() { + return ( +
+ Operational layout — coming soon +
+ ); +} + +function AnalyticsLayout() { + return ( +
+ Analytics layout — coming soon +
+ ); +} + +// --- Main component --- + +type ViewMode = "desktop" | "compact" | "stacked"; + +function useViewMode(ref: React.RefObject): ViewMode { + const [mode, setMode] = useState("desktop"); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new ResizeObserver(([entry]) => { + const w = entry.contentRect.width; + if (w >= 1100) setMode("desktop"); + else if (w >= 800) setMode("compact"); + else setMode("stacked"); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [ref]); + + return mode; +} + +function DashboardContentInner() { + const { data } = useDashboardData(); + const [layout] = useState("executive"); + const [darkGlow, setDarkGlow] = useState(defaultDarkGlow); + const [darkCards, setDarkCards] = useState(defaultDarkCards); + const [layoutCfg, setLayoutCfg] = useState(defaultLayout); + const [autopilotOpen, setAutopilotOpen] = useState(false); + const [autopilotSource, setAutopilotSource] = useState(""); + const [autopilotActiveIdx, setAutopilotActiveIdx] = useState( + null, + ); + const containerRef = useRef(null); + const viewMode = useViewMode(containerRef); + + const handleAutopilotOpen = (sourceTitle: string, idx: number) => { + if (autopilotOpen && autopilotActiveIdx === idx) { + setAutopilotOpen(false); + setAutopilotActiveIdx(null); + } else { + setAutopilotSource(sourceTitle); + setAutopilotActiveIdx(idx); + setAutopilotOpen(true); + } + }; + + const handleAutopilotClose = () => { + setAutopilotOpen(false); + setAutopilotActiveIdx(null); + }; + + return ( +
+ +
+ {/* Header — stays in place */} +
+
+

+ {data.brandName}{" "} + {data.brandLine} +

+

+ {data.dashboardTitle} + + {data.badgeText} + +

+
+
+ + +
+
+ + {/* Layout content */} +
+ {/* Dashboard cards — shifts left for autopilot */} +
+ {layout === "executive" && ( + + )} + {layout === "operational" && } + {layout === "analytics" && } +
+ {/* Autopilot panel — slides in from right */} +
+
+ +
+
+
+
+
+ ); +} + export function DashboardContent() { - return null; + return ( + + + + ); } diff --git a/apps/apollo-vertex/templates/dashboard/DashboardGlow.tsx b/apps/apollo-vertex/templates/dashboard/DashboardGlow.tsx new file mode 100644 index 000000000..953d42119 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardGlow.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { + defaultDarkGlow, + defaultLightGlow, + type GlowConfig, +} from "./glow-config"; + +interface DashboardGlowProps { + className?: string; + darkConfig?: GlowConfig; +} + +function GlowSvg({ id, config }: { id: string; config: GlowConfig }) { + return ( + + + + + + + + + + + + + + + + + ); +} + +export function DashboardGlow({ className, darkConfig }: DashboardGlowProps) { + const light = defaultLightGlow; + const dark = darkConfig ?? defaultDarkGlow; + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx b/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx index 5e2d6ae00..b4b3dd48f 100644 --- a/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx +++ b/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx @@ -43,15 +43,24 @@ const routeTree = dashboardRootRoute.addChildren([ ]), ]); +const BASE_PATHS = { + default: "/preview/dashboard/", + minimal: "/preview/dashboard-minimal/", +} as const; + +function normalizeEntry(path: string): string { + if (path === "/preview/dashboard") return BASE_PATHS.default; + if (path === "/preview/dashboard-minimal") return BASE_PATHS.minimal; + return path; +} + function getInitialEntry( storageKey: DashboardPreviewPathKey, variant?: "minimal", ) { const stored = localStorage.getItem(storageKey); - if (stored) return stored; - return variant === "minimal" - ? "/preview/dashboard-minimal" - : "/preview/dashboard"; + if (stored) return normalizeEntry(stored); + return variant === "minimal" ? BASE_PATHS.minimal : BASE_PATHS.default; } function createDashboardRouter( @@ -75,7 +84,7 @@ export function DashboardTemplate({ shellVariant }: DashboardTemplateProps) { useEffect(() => { const unsubscribe = router.subscribe("onResolved", ({ toLocation }) => { - localStorage.setItem(storageKey, toLocation.pathname); + localStorage.setItem(storageKey, normalizeEntry(toLocation.pathname)); }); return unsubscribe; }, [router, storageKey]); diff --git a/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx b/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx index 71f1fd239..a15e659c2 100644 --- a/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx +++ b/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx @@ -291,15 +291,17 @@ export function AutopilotPrompts({
Autopilot Autopilot - Ask Autopilot + + Ask AI Assistant +
{suggestedPrompts.map((prompt) => ( diff --git a/apps/apollo-vertex/templates/dashboard/GlowDevControls.tsx b/apps/apollo-vertex/templates/dashboard/GlowDevControls.tsx new file mode 100644 index 000000000..0b34920aa --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/GlowDevControls.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useRef, useState } from "react"; +import { useDashboardData } from "./dashboard-data-context"; +import { datasetPresets, type DashboardDataset } from "./dashboard-data"; +import { CardsTab, GlowTab, LayoutTab } from "./dev-controls-tabs"; +import type { CardConfig, GlowConfig, LayoutConfig } from "./glow-config"; + +interface DevControlsProps { + glowConfig: GlowConfig; + onGlowChange: (config: GlowConfig) => void; + cardConfig: CardConfig; + onCardChange: (config: CardConfig) => void; + layoutConfig: LayoutConfig; + onLayoutChange: (config: LayoutConfig) => void; +} + +type Tab = "glow" | "cards" | "layout" | "data"; + +export function GlowDevControls({ + glowConfig, + onGlowChange, + cardConfig, + onCardChange, + layoutConfig, + onLayoutChange, +}: DevControlsProps) { + const [open, setOpen] = useState(false); + const [tab, setTab] = useState("glow"); + const { data, setDataset } = useDashboardData(); + const fileInputRef = useRef(null); + const [uploadError, setUploadError] = useState(""); + const [uploadedDatasets, setUploadedDatasets] = useState( + [], + ); + + const configMap: Record = { + glow: glowConfig, + cards: cardConfig, + layout: layoutConfig, + data: data, + }; + const currentConfig = configMap[tab]; + + return ( + <> + { + const file = e.target.files?.[0]; + if (!file) return; + setUploadError(""); + void file.text().then((text) => { + try { + // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- JSON.parse returns untyped data + const parsed = JSON.parse(text) as unknown as DashboardDataset; + if (!parsed.brandName || !parsed.insightCards) { + setUploadError("Invalid format"); + return; + } + setUploadedDatasets((prev) => { + const exists = prev.some((d) => d.name === parsed.name); + return exists + ? prev.map((d) => (d.name === parsed.name ? parsed : d)) + : [...prev, parsed]; + }); + setDataset(parsed); + } catch { + setUploadError("Invalid JSON"); + } + }); + e.target.value = ""; + }} + /> +
+ {open && ( +
+
+ + + + +
+
+ {tab === "glow" && ( + + )} + {tab === "cards" && ( + + )} + {tab === "layout" && ( + + )} + {tab === "data" && ( +
+
+ Dataset: {data.name} +
+
+
Preset
+ +
+
+ + +
+ {uploadError && ( +
+ {uploadError} +
+ )} +
+ )} +
+
Config:
+
+                  {JSON.stringify(currentConfig, null, 2)}
+                
+
+
+
+ )} + +
+ + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/InsightCardInner.tsx b/apps/apollo-vertex/templates/dashboard/InsightCardInner.tsx new file mode 100644 index 000000000..35918e990 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/InsightCardInner.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { ArrowUpRight, Maximize2, Minimize2 } from "lucide-react"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useDashboardData } from "./dashboard-data-context"; +import { type DrilldownTab, drilldownTabs } from "./drilldown-tabs"; +import { + AutopilotPrompts, + DrilldownTabContent, +} from "./ExpandedInsightContent"; +import { + type CardConfig, + cardBgStyle, + getInsightCardClasses, + type InsightCardConfig, +} from "./glow-config"; +import type { ExpandPhase } from "./InsightGrid"; +import { InsightCardBody } from "./insight-card-renderers"; + +interface InsightCardInnerProps { + cfg: InsightCardConfig; + cardIndex: number; + shared: string; + cards: CardConfig; + isExpanding: boolean; + isThis: boolean; + phase: ExpandPhase; + viewMode: "desktop" | "compact" | "stacked"; + drilldownTab: DrilldownTab; + onDrilldownTabChange: (tab: DrilldownTab) => void; + onExpandClick: () => void; + onAutopilotOpen?: (() => void) | null; + isAutopilotActive?: boolean; + className?: string; + style?: React.CSSProperties; +} + +export function InsightCardInner({ + cfg, + cardIndex, + shared, + cards, + isExpanding, + isThis, + phase, + viewMode, + onExpandClick, + onAutopilotOpen, + drilldownTab, + onDrilldownTabChange, + isAutopilotActive = false, + className = "", + style, +}: InsightCardInnerProps) { + const { data } = useDashboardData(); + const cardTitle = data.insightCards[cardIndex]?.title ?? cfg.content.title; + const hasDrilldown = cfg.content.chartType === "horizontal-bars"; + const isExpandedWithDrilldown = + isThis && isExpanding && hasDrilldown && phase === "full"; + const classes = getInsightCardClasses(cfg.content, viewMode); + const isInteractive = cfg.interaction !== "static"; + + return ( + + + + {cardTitle} + + {isInteractive && cfg.interaction === "expand" && ( + +
+ {onAutopilotOpen && ( + + )} + +
+
+ )} + {isInteractive && cfg.interaction === "navigate" && !isThis && ( + + + + )} + {/* Drilldown tabs — below title when expanded */} + {isThis && + isExpanding && + hasDrilldown && + (phase === "height" || phase === "full") && + (() => { + const visibleTabs = drilldownTabs.slice(0, 4); + const overflowTabs = drilldownTabs.slice(4); + const isOverflowActive = overflowTabs.some( + (t) => t.key === drilldownTab, + ); + return ( +
+ {visibleTabs.map((tab) => ( + + ))} + {overflowTabs.length > 0 && ( +
+ + + + +
+ )} +
+ ); + })()} +
+ {isExpandedWithDrilldown ? ( + /* Expanded with drilldown — unified layout for all tabs */ +
+
+
+ {drilldownTab === "overview" ? ( + + ) : ( + + )} +
+
+
+ onAutopilotOpen?.()} /> +
+
+ ) : ( + /* Default card content — not expanded or no drilldown */ + + + + )} + {/* Non-drilldown expanded content (other card types) */} + {isThis && + isExpanding && + !hasDrilldown && + (phase === "height" || phase === "full") && ( +
+ {phase === "full" ? ( +
+ + Additional content + +
+ ) : ( +
+
+
+
+
+ )} +
+ )} + + ); +} + +export type { InsightCardInnerProps }; diff --git a/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx b/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx index e57ca42a2..496696b68 100644 --- a/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx +++ b/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx @@ -1,293 +1,18 @@ "use client"; import { useEffect, useState } from "react"; -import { ArrowUpRight, Maximize2, Minimize2 } from "lucide-react"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useDashboardData } from "./dashboard-data-context"; +import type { DrilldownTab } from "./drilldown-tabs"; import { - cardBgStyle, - getInsightCardClasses, - type CardConfig, - type InsightCardConfig, - type LayoutConfig, -} from "./glow-config"; -import { InsightCardBody } from "./insight-card-renderers"; -import { useDashboardData } from "./DashboardDataProvider"; -import { - type DrilldownTab, - drilldownTabs, - DrilldownTabContent, AutopilotPrompts, + DrilldownTabContent, } from "./ExpandedInsightContent"; +import type { CardConfig, LayoutConfig } from "./glow-config"; +import { InsightCardInner } from "./InsightCardInner"; -const sizeToFr: Record = { sm: "1fr", md: "2fr", lg: "1fr" }; - -// --- Shared card inner content --- - -interface InsightCardInnerProps { - cfg: InsightCardConfig; - cardIndex: number; - shared: string; - cards: CardConfig; - isExpanding: boolean; - isThis: boolean; - phase: ExpandPhase; - viewMode: "desktop" | "compact" | "stacked"; - drilldownTab: DrilldownTab; - onDrilldownTabChange: (tab: DrilldownTab) => void; - onExpandClick: () => void; - onAutopilotOpen?: () => void; - isAutopilotActive?: boolean; - className?: string; - style?: React.CSSProperties; -} - -function InsightCardInner({ - cfg, - cardIndex, - shared, - cards, - isExpanding, - isThis, - phase, - viewMode, - onExpandClick, - onAutopilotOpen, - drilldownTab, - onDrilldownTabChange, - isAutopilotActive = false, - className = "", - style, -}: InsightCardInnerProps) { - const { data } = useDashboardData(); - const cardTitle = data.insightCards[cardIndex]?.title ?? cfg.content.title; - const hasDrilldown = cfg.content.chartType === "horizontal-bars"; - const isExpandedWithDrilldown = - isThis && isExpanding && hasDrilldown && phase === "full"; - const classes = getInsightCardClasses(cfg.content, viewMode); - const isInteractive = cfg.interaction !== "static"; - - return ( - - - - {cardTitle} - - {isInteractive && cfg.interaction === "expand" && ( - -
- {onAutopilotOpen && ( - - )} - -
-
- )} - {isInteractive && cfg.interaction === "navigate" && !isThis && ( - - - - )} - {/* Drilldown tabs — below title when expanded */} - {isThis && - isExpanding && - hasDrilldown && - (phase === "height" || phase === "full") && - (() => { - const visibleTabs = drilldownTabs.slice(0, 4); - const overflowTabs = drilldownTabs.slice(4); - const isOverflowActive = overflowTabs.some( - (t) => t.key === drilldownTab, - ); - return ( -
- {visibleTabs.map((tab) => ( - - ))} - {overflowTabs.length > 0 && ( -
- - - - -
- )} -
- ); - })()} -
- {isExpandedWithDrilldown ? ( - /* Expanded with drilldown — unified layout for all tabs */ -
-
-
- {drilldownTab === "overview" ? ( - - ) : ( - - )} -
-
-
- onAutopilotOpen?.()} /> -
-
- ) : ( - /* Default card content — not expanded or no drilldown */ - - - - )} - {/* Non-drilldown expanded content (other card types) */} - {isThis && - isExpanding && - !hasDrilldown && - (phase === "height" || phase === "full") && ( -
- {phase === "full" ? ( -
- - Additional content - -
- ) : ( -
-
-
-
-
- )} -
- )} - - ); -} - -// --- Main grid --- +export type ExpandPhase = "idle" | "width" | "height" | "full"; -type ExpandPhase = "idle" | "width" | "height" | "full"; +const sizeToFr: Record = { sm: "1fr", md: "2fr", lg: "1fr" }; export function InsightGrid({ layout, @@ -351,7 +76,10 @@ export function InsightGrid({ ? rows.findIndex((row) => row.some(({ idx }) => idx === expandedIdx)) : -1; - const handleClick = (cfg: InsightCardConfig, idx: number) => { + const handleClick = ( + cfg: (typeof visibleCards)[number]["cfg"], + idx: number, + ) => { if (cfg.interaction === "expand") { setExpandedIdx(expandedIdx === idx ? null : idx); } @@ -423,7 +151,7 @@ export function InsightGrid({ data.insightCards[idx]?.title ?? cfg.content.title, idx, ) - : undefined + : null } isAutopilotActive={autopilotActiveIdx === idx} className="h-full" @@ -487,7 +215,7 @@ export function InsightGrid({ cfg.content.title, idx, ) - : undefined + : null } isAutopilotActive={autopilotActiveIdx === idx} style={{ @@ -506,3 +234,7 @@ export function InsightGrid({
); } + +// Re-export for consumers that import these from InsightGrid +export { DrilldownTabContent, AutopilotPrompts }; +export type { DrilldownTab }; diff --git a/apps/apollo-vertex/templates/dashboard/PromptBar.tsx b/apps/apollo-vertex/templates/dashboard/PromptBar.tsx index 9bd863f88..14c053003 100644 --- a/apps/apollo-vertex/templates/dashboard/PromptBar.tsx +++ b/apps/apollo-vertex/templates/dashboard/PromptBar.tsx @@ -1,29 +1,10 @@ "use client"; -import { useState } from "react"; import { MessagesSquare, Minimize2 } from "lucide-react"; +import { useState } from "react"; import { Badge } from "@/components/ui/badge"; -import type { CardConfig, CardGradient } from "./glow-config"; -import { useDashboardData } from "./DashboardDataProvider"; - -function cardBgStyle( - bg: string, - opacity: number, - gradient: CardGradient, -): React.CSSProperties { - if (gradient.enabled) { - const alpha = gradient.opacity / 100; - return { - "--card-bg-override": `linear-gradient(${gradient.angle}deg, color-mix(in srgb, ${gradient.start} ${alpha * 100}%, transparent), color-mix(in srgb, ${gradient.end} ${alpha * 100}%, transparent))`, - borderColor: "transparent", - } as React.CSSProperties; - } - const value = - bg === "white" - ? `rgba(255,255,255,${opacity / 100})` - : `color-mix(in srgb, var(--${bg}) ${opacity}%, transparent)`; - return { "--card-bg-override": value } as React.CSSProperties; -} +import { useDashboardData } from "./dashboard-data-context"; +import { type CardConfig, cardBgStyle } from "./glow-config"; export function PromptBar({ shared, @@ -70,16 +51,16 @@ export function PromptBar({
Autopilot Autopilot - Autopilot + AI Assistant
{onCollapse && ( @@ -123,8 +104,7 @@ export function PromptBar({ className="!bg-white/35 !text-foreground opacity-0 translate-y-2 group-focus-within:opacity-100 group-focus-within:translate-y-0 transition-all duration-300 delay-75 cursor-pointer" onClick={() => handleChipClick( - data.promptSuggestions[1] ?? - "Compare Q1 vs Q2 performance", + data.promptSuggestions[1] ?? "Compare Q1 vs Q2 performance", ) } > diff --git a/apps/apollo-vertex/templates/dashboard/chart-stubs.tsx b/apps/apollo-vertex/templates/dashboard/chart-stubs.tsx new file mode 100644 index 000000000..ee56edb34 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/chart-stubs.tsx @@ -0,0 +1,94 @@ +"use client"; + +const sparklinePoints = [4, 7, 5, 9, 6, 8, 12, 10, 14, 11, 15, 13]; +const areaPoints = [3, 5, 4, 8, 6, 9, 7, 11, 10, 14, 12, 16]; + +export function DonutContent() { + return ( +
+
+ + + + +
+ + 47% + + + funded + +
+
+
+ ); +} + +export function SparklineContent() { + const max = Math.max(...sparklinePoints); + const h = 40; + const w = 120; + const step = w / (sparklinePoints.length - 1); + const points = sparklinePoints + .map((v, i) => `${i * step},${h - (v / max) * h}`) + .join(" "); + + return ( +
+ + + +
+ ); +} + +export function AreaContent() { + const max = Math.max(...areaPoints); + const h = 40; + const w = 120; + const step = w / (areaPoints.length - 1); + const linePoints = areaPoints + .map((v, i) => `${i * step},${h - (v / max) * h}`) + .join(" "); + const areaPath = `0,${h} ${linePoints} ${w},${h}`; + + return ( +
+ + + + +
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/dev-controls-primitives.tsx b/apps/apollo-vertex/templates/dashboard/dev-controls-primitives.tsx new file mode 100644 index 000000000..a779dd432 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/dev-controls-primitives.tsx @@ -0,0 +1,113 @@ +"use client"; + +export function Slider({ + label, + value, + min, + max, + step, + onChange, + displayValue, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + onChange: (v: number) => void; + displayValue?: string; +}) { + return ( +
+
+ {label} + {displayValue ?? value} +
+ onChange(Number(e.target.value))} + className="w-full h-1 accent-primary" + /> +
+ ); +} + +export function SelectControl({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: { label: string; value: string }[]; + onChange: (v: string) => void; +}) { + return ( +
+
{label}
+ +
+ ); +} + +export function Toggle({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +export function TextInput({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; +}) { + return ( +
+
{label}
+ onChange(e.target.value)} + className="w-full h-7 rounded border bg-background px-1 text-xs" + placeholder={placeholder} + /> +
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/dev-controls-tabs.tsx b/apps/apollo-vertex/templates/dashboard/dev-controls-tabs.tsx new file mode 100644 index 000000000..0ffc3c51d --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/dev-controls-tabs.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { + SelectControl, + Slider, + TextInput, + Toggle, +} from "./dev-controls-primitives"; +import { + bgColorOptions, + cardTypeOptions, + chartTypeOptions, + containerBgOptions, + insightOptions, + interactionOptions, + isCardInteraction, + isCardSize, + isChartType, + isInsightCardType, + primaryOptions, + sizeOptions, + type CardConfig, + type CardGradient, + type GlowConfig, + type InsightCardConfig, + type LayoutConfig, +} from "./glow-config"; + +function GradientSection({ + gradient, + onChange, +}: { + gradient: CardGradient; + onChange: (g: CardGradient) => void; +}) { + const update = (partial: Partial) => + onChange({ ...gradient, ...partial }); + + return ( +
+ update({ enabled: v })} + /> + {gradient.enabled && ( + <> + update({ start: v })} + /> + update({ end: v })} + /> + update({ angle: v })} + displayValue={`${gradient.angle}°`} + /> + update({ opacity: v })} + displayValue={`${gradient.opacity}%`} + /> + + )} +
+ ); +} + +export function GlowTab({ + config, + onChange, +}: { + config: GlowConfig; + onChange: (c: GlowConfig) => void; +}) { + const update = (partial: Partial) => + onChange({ ...config, ...partial }); + + return ( +
+ update({ start: v })} + /> + update({ end: v })} + /> + update({ containerOpacity: v })} + displayValue={`${config.containerOpacity}%`} + /> + update({ fillOpacity: v })} + /> + update({ startStopOpacity: v })} + /> + update({ endStopOpacity: v })} + /> + update({ endOffset: v })} + /> +
+ ); +} + +export function CardsTab({ + config, + onChange, +}: { + config: CardConfig; + onChange: (c: CardConfig) => void; +}) { + const update = (partial: Partial) => + onChange({ ...config, ...partial }); + + return ( +
+
Overview Card
+ update({ overviewBg: v })} + /> + update({ overviewOpacity: v })} + displayValue={`${config.overviewOpacity}%`} + /> + update({ overviewGradient: g })} + /> + +
+ Insight Cards +
+ update({ insightBg: v })} + /> + update({ insightOpacity: v })} + displayValue={`${config.insightOpacity}%`} + /> + update({ insightGradient: g })} + /> + +
Prompt Bar
+ update({ promptBg: v })} + /> + update({ promptOpacity: v })} + displayValue={`${config.promptOpacity}%`} + /> + update({ promptGradient: g })} + /> + +
Shared
+ update({ borderVisible: v })} + /> + update({ backdropBlur: v })} + /> +
+ ); +} + +export function LayoutTab({ + config, + onChange, +}: { + config: LayoutConfig; + onChange: (c: LayoutConfig) => void; +}) { + const update = (partial: Partial) => + onChange({ ...config, ...partial }); + + const updateInsightCard = ( + index: number, + partial: Partial, + ) => { + const cards = [...config.insightCards] as [ + InsightCardConfig, + InsightCardConfig, + InsightCardConfig, + InsightCardConfig, + ]; + cards[index] = { ...cards[index], ...partial }; + update({ insightCards: cards }); + }; + + return ( +
+ update({ containerBg: v })} + /> + update({ gap: v })} + displayValue={`${config.gap}px`} + /> + update({ padding: v })} + displayValue={`${config.padding}px`} + /> +
Left Column
+ update({ overviewRatio: v })} + /> + update({ promptRatio: v })} + /> +
+ Insight Cards +
+ {["Top Left", "Top Right", "Bottom Left", "Bottom Right"].map( + (label, i) => ( +
+
+ {label} + updateInsightCard(i, { visible: v })} + /> +
+ {config.insightCards[i].visible && ( + <> + { + if (isCardSize(v)) updateInsightCard(i, { size: v }); + }} + /> + { + if (isInsightCardType(v)) + updateInsightCard(i, { + content: { ...config.insightCards[i].content, type: v }, + }); + }} + /> + {config.insightCards[i].content.type === "chart" && ( + { + if (isChartType(v)) + updateInsightCard(i, { + content: { + ...config.insightCards[i].content, + chartType: v, + }, + }); + }} + /> + )} + + updateInsightCard(i, { + content: { ...config.insightCards[i].content, title: v }, + }) + } + /> + { + if (isCardInteraction(v)) + updateInsightCard(i, { interaction: v }); + }} + /> + {config.insightCards[i].interaction === "navigate" && ( + updateInsightCard(i, { navigateTo: v })} + placeholder="/preview/dashboard/..." + /> + )} + + )} +
+ ), + )} +
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/drilldown-tabs.ts b/apps/apollo-vertex/templates/dashboard/drilldown-tabs.ts new file mode 100644 index 000000000..536b1f220 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/drilldown-tabs.ts @@ -0,0 +1,13 @@ +export type DrilldownTab = + | "overview" + | "trend" + | "categories" + | "products" + | "actions"; + +export const drilldownTabs: { key: DrilldownTab; label: string }[] = [ + { key: "overview", label: "Overview" }, + { key: "categories", label: "Categories" }, + { key: "products", label: "Products" }, + { key: "actions", label: "Actions" }, +];