Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/app/platform/fleetops/components/dark-code-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use client';

import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';

import { cn } from '@/lib/utils';

/**
* Dark code panel — always-dark themed. One integrated chrome: title bar
* with live-pulse dot + label + copy button, then the code body directly
* underneath. No outer wrapper, no double chrome.
*
* Different from the shared CodeBlock component (which follows the global
* light/dark theme); this is locked to dark so it reads as a "console" on
* the light Fleet-Ops landing page.
*/
export function DarkCodePanel({
label,
language = 'javascript',
code,
className,
}: {
label: string;
language?: string;
code: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};

return (
<div
className={cn(
'fo-card overflow-hidden border border-white/10 bg-[#0d1117] text-white shadow-[0_30px_60px_-30px_rgba(0,0,0,0.45)]',
className,
)}
>
{/* Title bar — pulse dot + label + copy button, all in one row */}
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-[#0a0d12] px-5 py-3">
<div className="flex items-center gap-2.5 text-[11px] font-medium uppercase tracking-[0.7px] text-white/65">
<span className="fo-pulse-dot" />
{label}
</div>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-white/65 transition-colors hover:bg-white/5 hover:text-white"
aria-label="Copy code"
>
{copied ? (
<>
<Check className="size-3.5" /> Copied
</>
) : (
<>
<Copy className="size-3.5" /> Copy
</>
)}
</button>
</div>

{/* Code body */}
<div className="overflow-auto">
<SyntaxHighlighter
language={language}
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '1.25rem 1.25rem',
background: 'transparent',
fontSize: '0.85rem',
lineHeight: '1.65',
}}
showLineNumbers
lineNumberStyle={{
color: 'rgba(255,255,255,0.20)',
paddingRight: '1.5rem',
minWidth: '2.25rem',
userSelect: 'none',
}}
>
{code}
</SyntaxHighlighter>
</div>
</div>
);
}
181 changes: 181 additions & 0 deletions src/app/platform/fleetops/components/layer-stack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
'use client';

import {
BarChart3,
LayoutDashboard,
type LucideIcon,
Radio,
Users,
Wrench,
} from 'lucide-react';
import { useState } from 'react';

import { cn } from '@/lib/utils';

type Layer = {
id: string;
icon: LucideIcon;
name: string;
tag: string;
description: string;
capabilities: string[];
};

const layers: Layer[] = [
{
id: 'operations',
icon: LayoutDashboard,
name: 'Operations',
tag: 'Dispatch & Configuration',
description:
'The dispatch command center. Phase-based orchestration, scheduling, order configuration, and service rates — everything that turns an incoming order into a completed delivery.',
capabilities: ['Orchestrator Workbench', 'Scheduler', 'Order Configuration', 'Service Rates'],
},
{
id: 'resources',
icon: Users,
name: 'Resources',
tag: 'People, Vehicles, Places',
description:
'Every person, vehicle, and location your operation depends on — managed, assigned, and tracked in one place.',
capabilities: ['Drivers & Vehicles', 'Fleets', 'Vendors & Contacts', 'Places & Fuel Reports'],
},
{
id: 'maintenance',
icon: Wrench,
name: 'Maintenance',
tag: 'Vehicle Health',
description:
'Keep every vehicle roadworthy. Preventive schedules, work orders, parts inventory, and fault reporting wired into the same fleet data as dispatch.',
capabilities: ['Preventive Schedules', 'Work Orders', 'Parts Inventory', 'Fault Reporting'],
},
{
id: 'connectivity',
icon: Radio,
name: 'Connectivity',
tag: 'Telematics & Sensors',
description:
'Bridge the physical and digital fleet. Telematics providers and IoT devices stream live location, speed, fuel, and sensor data into one operational view.',
capabilities: ['Samsara · Geotab · Flespi', 'GPS Devices', 'Sensor Ingestion', 'Device Event Log'],
},
{
id: 'analytics',
icon: BarChart3,
name: 'Analytics',
tag: 'Reports & Insights',
description:
'Surface the metrics that drive decisions — delivery performance, cost-per-route, driver scoring, and any custom report you can build.',
capabilities: ['KPI Dashboards', 'SLA Tracking', 'Cost-per-Delivery', 'Driver Scorecards'],
},
];

/**
* "Why Fleet-Ops" stacked-layer visual. Each layer is a tilted card; clicking
* a layer lifts it and shows its description on the right.
*
* Inspired by spoke.com/dispatch's clickable stack component.
*/
export function LayerStack() {
const [activeId, setActiveId] = useState<string>(layers[0].id);
const active = layers.find((l) => l.id === activeId) ?? layers[0];

return (
<div className="grid items-center gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-16">
{/* Layer stack */}
<div className="fo-layer-stack relative flex flex-col gap-2.5" role="tablist" aria-label="Fleet-Ops modules">
{layers.map((layer, i) => {
const Icon = layer.icon;
const isActive = layer.id === activeId;
return (
<button
key={layer.id}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`fo-module-panel-${layer.id}`}
id={`fo-module-tab-${layer.id}`}
data-active={isActive}
onClick={() => setActiveId(layer.id)}
className={cn(
'fo-layer group flex items-center gap-4 rounded-2xl border bg-white p-4 text-left transition-colors',
'hover:border-[var(--fo-blue)]/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--fo-blue)]/40',
isActive
? 'border-[var(--fo-blue)] shadow-[0_25px_60px_-25px_oklch(0.65_0.154_254/0.35)]'
: 'border-[var(--fo-border)]',
)}
>
<div
className={cn(
'flex size-11 shrink-0 items-center justify-center rounded-xl transition-colors',
isActive
? 'bg-[var(--fo-blue)] text-white'
: 'bg-[var(--fo-blue-tint)] text-[var(--fo-blue)] group-hover:bg-[var(--fo-blue-soft)]',
)}
>
<Icon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-base font-semibold text-[var(--fo-fg-strong)]">
{layer.name}
</span>
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--fo-fg-soft)]">
{layer.tag}
</span>
</div>
<p className="mt-0.5 text-xs leading-snug text-[var(--fo-fg-muted)] line-clamp-1">
{layer.description}
</p>
</div>
<span
className={cn(
'text-[10px] font-mono tracking-wider transition-colors',
isActive ? 'text-[var(--fo-blue)]' : 'text-[var(--fo-fg-soft)]',
)}
>
0{i + 1}
</span>
</button>
);
})}
</div>

{/* Active layer detail */}
<div
role="tabpanel"
id={`fo-module-panel-${active.id}`}
aria-labelledby={`fo-module-tab-${active.id}`}
className="rounded-3xl border border-[var(--fo-border)] bg-white p-8 shadow-sm"
>
<div className="flex items-center gap-4">
<div className="flex size-12 items-center justify-center rounded-xl bg-[var(--fo-blue)] text-white">
<active.icon className="size-5" />
</div>
<div>
<div className="text-[10px] font-medium uppercase tracking-[0.7px] text-[var(--fo-blue)]">
Module {String(layers.findIndex((l) => l.id === active.id) + 1).padStart(2, '0')} ·{' '}
{active.tag}
</div>
<h3 className="text-2xl font-[680] tracking-tight text-[var(--fo-fg-strong)]">
{active.name}
</h3>
</div>
</div>
<p className="mt-6 text-base leading-relaxed text-[var(--fo-fg-muted)]">
{active.description}
</p>
<ul className="mt-6 grid grid-cols-2 gap-x-4 gap-y-2.5">
{active.capabilities.map((cap) => (
<li
key={cap}
className="flex items-center gap-2 text-sm text-[var(--fo-fg-muted)]"
>
<span className="size-1.5 shrink-0 rounded-full bg-[var(--fo-blue)]" />
{cap}
</li>
))}
</ul>
</div>
</div>
);
}
118 changes: 118 additions & 0 deletions src/app/platform/fleetops/components/spine-progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
'use client';

import { useEffect, useState } from 'react';

import { cn } from '@/lib/utils';

type BeatDef = { id: string; label: string };

/**
* Sticky vertical progress indicator that tracks which SpineBeat is closest
* to the viewport center as the user scrolls through the spine section.
*
* Hidden below lg — the spine section on mobile is full-width and a side
* indicator would just clutter.
*
* Implementation: an IntersectionObserver watches the elements named in
* `beats` and tracks intersection ratios. The dot whose corresponding beat
* has the highest intersection ratio is rendered as active.
*/
export function SpineProgress({ beats }: { beats: BeatDef[] }) {
const [activeId, setActiveId] = useState<string>(beats[0]?.id ?? '');
// The indicator is only visible when at least one beat is intersecting
// the viewport. Outside the spine section it hides entirely so it
// doesn't sit on top of unrelated content (hero, automate, CTA, etc.).
const [visible, setVisible] = useState(false);

useEffect(() => {
if (typeof window === 'undefined') return;
const elements = beats
.map((b) => document.getElementById(b.id))
.filter((el): el is HTMLElement => el !== null);
if (elements.length === 0) return;

// Keep a map of id -> current intersection ratio.
const ratios = new Map<string, number>();

const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
ratios.set(entry.target.id, entry.intersectionRatio);
}
// Pick the id with the highest ratio.
let best = activeId;
let bestRatio = -1;
for (const [id, r] of ratios) {
if (r > bestRatio) {
bestRatio = r;
best = id;
}
}
if (best && best !== activeId) setActiveId(best);
setVisible(bestRatio > 0);
},
{
// Track several thresholds so we know which beat is most visible.
threshold: [0, 0.1, 0.25, 0.5, 0.75, 1],
// Bias toward the middle of the viewport — beats near the top/bottom
// shouldn't take priority just because they're poking in.
rootMargin: '-30% 0px -30% 0px',
},
);

elements.forEach((el) => observer.observe(el));
return () => observer.disconnect();
// We intentionally depend only on `beats` here — re-running on every
// activeId change would tear down and rebuild the observer.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [beats]);

const scrollTo = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};

return (
<div
aria-hidden
className={cn(
'pointer-events-none fixed top-1/2 right-6 z-30 hidden -translate-y-1/2 flex-col items-center gap-3 transition-opacity duration-300 lg:flex',
visible ? 'opacity-100' : 'opacity-0',
)}
>
{beats.map((beat) => {
const isActive = beat.id === activeId;
return (
<button
key={beat.id}
type="button"
onClick={() => scrollTo(beat.id)}
title={beat.label}
aria-label={`Jump to ${beat.label}`}
className={cn(
'pointer-events-auto group relative flex items-center transition-all',
)}
>
<span
className={cn(
'inline-block size-2 rounded-full transition-all',
isActive
? 'bg-[var(--fo-blue)] ring-4 ring-[var(--fo-blue)]/15'
: 'bg-[var(--fo-border)] group-hover:bg-[var(--fo-blue)]/50',
)}
/>
<span
className={cn(
'pointer-events-none absolute right-5 rounded-md bg-white px-2 py-0.5 text-[11px] font-medium whitespace-nowrap text-[var(--fo-fg-strong)] shadow-sm transition-opacity',
isActive
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100',
)}
>
{beat.label}
</span>
</button>
);
})}
</div>
);
}
Loading