mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-16 11:31:04 +02:00
chore: tooltip design enhancements (#532)
This commit is contained in:
parent
e8b5ea3888
commit
f5de6e1bca
5 changed files with 130 additions and 71 deletions
|
|
@ -16,6 +16,7 @@
|
|||
"dependencies": {
|
||||
"@calcom/embed-react": "^1.5.3",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@floating-ui/react-dom": "^2.1.9",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
|
|
|
|||
|
|
@ -150,6 +150,45 @@
|
|||
animation: spin-slow 3s linear infinite;
|
||||
}
|
||||
|
||||
/* Attention pulse applied by OnboardingTooltip to the element its arrow points
|
||||
at; removed automatically when the tooltip is dismissed. Blue matches the
|
||||
tooltip body so the ring and the callout read as one unit. Two shadow layers:
|
||||
a constant outline so the target stays highlighted between pulses, plus an
|
||||
expanding wave, with a slight breathing scale on top. */
|
||||
.onboarding-pulse {
|
||||
animation: onboarding-pulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes onboarding-pulse {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(59, 130, 246, 0.75),
|
||||
0 0 0 3px rgba(59, 130, 246, 0.6);
|
||||
transform: scale(1);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
70% {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(59, 130, 246, 0.75),
|
||||
0 0 0 18px rgba(59, 130, 246, 0);
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(59, 130, 246, 0.75),
|
||||
0 0 0 18px rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onboarding-pulse {
|
||||
animation: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* CSS-only audio-waveform motif for the auth brand panel. A row of bars that
|
||||
breathe at staggered intervals, evoking live voice. Decorative only. */
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function WorkflowTesterPanel({
|
|||
onRuntimeNodeTransition,
|
||||
}: WorkflowTesterPanelProps) {
|
||||
const auth = useAuth();
|
||||
const { hasSeenTooltip, markTooltipSeen, markActionCompleted } = useOnboarding();
|
||||
const { markActionCompleted } = useOnboarding();
|
||||
const { isAuthenticated, loading: authLoading, getAccessToken } = auth;
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [activeMode, setActiveMode] = useState<"audio" | "text">("audio");
|
||||
|
|
@ -113,7 +113,6 @@ export function WorkflowTesterPanel({
|
|||
}
|
||||
|
||||
markActionCompleted("web_call_started");
|
||||
markTooltipSeen("web_call");
|
||||
posthog.capture(PostHogEvent.WEB_CALL_INITIATED, {
|
||||
workflow_id: workflowId,
|
||||
workflow_run_id: response.data.id,
|
||||
|
|
@ -126,22 +125,21 @@ export function WorkflowTesterPanel({
|
|||
} finally {
|
||||
setCreatingVoiceRun(false);
|
||||
}
|
||||
}, [accessToken, disabled, markActionCompleted, markTooltipSeen, workflowId]);
|
||||
}, [accessToken, disabled, markActionCompleted, workflowId]);
|
||||
|
||||
const authUnavailableReason = tokenReady && !accessToken
|
||||
? "Authentication is required before testing can start."
|
||||
: null;
|
||||
const effectiveDisabledReason = disabledReason ?? authUnavailableReason;
|
||||
const testerBlocked = disabled || authUnavailableReason !== null;
|
||||
const showRunTestTooltip =
|
||||
const runTestTooltipEnabled =
|
||||
showWebCallOnboarding &&
|
||||
isVisible &&
|
||||
activeMode === "audio" &&
|
||||
!voiceRunId &&
|
||||
tokenReady &&
|
||||
!!accessToken &&
|
||||
!testerBlocked &&
|
||||
!hasSeenTooltip("web_call");
|
||||
!testerBlocked;
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full min-h-0 flex-col bg-background", className)}>
|
||||
|
|
@ -265,12 +263,12 @@ export function WorkflowTesterPanel({
|
|||
</Tabs>
|
||||
|
||||
<OnboardingTooltip
|
||||
tooltipKey="web_call"
|
||||
targetRef={runTestButtonRef}
|
||||
title="Try Your First Web Call"
|
||||
message="Start a browser call here to hear the agent, inspect the transcript, and validate the workflow before you customize it further."
|
||||
onDismiss={() => markTooltipSeen("web_call")}
|
||||
showNext={false}
|
||||
isVisible={showRunTestTooltip}
|
||||
enabled={runTestTooltipEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { ConversationRailFrame, RealtimeFeedback, WorkflowRunLogs } from '@/components/workflow/conversation';
|
||||
import { PostHogEvent } from '@/constants/posthog-events';
|
||||
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
|
||||
import { useOnboarding } from '@/context/OnboardingContext';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { downloadFile, getSignedUrl } from '@/lib/files';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -602,7 +601,6 @@ export default function WorkflowRunPage() {
|
|||
const auth = useAuth();
|
||||
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
|
||||
const [workflowName, setWorkflowName] = useState<string | null>(null);
|
||||
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
|
||||
const customizeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Redirect if not authenticated
|
||||
|
|
@ -747,11 +745,6 @@ export default function WorkflowRunPage() {
|
|||
<Button
|
||||
ref={customizeButtonRef}
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
if (!hasSeenTooltip('customize_workflow')) {
|
||||
markTooltipSeen('customize_workflow');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
|
|
@ -898,12 +891,11 @@ export default function WorkflowRunPage() {
|
|||
{/* Onboarding Tooltip for Customize Workflow */}
|
||||
{showRunDetailsView && (
|
||||
<OnboardingTooltip
|
||||
tooltipKey="customize_workflow"
|
||||
title='Customize Your Workflow'
|
||||
targetRef={customizeButtonRef}
|
||||
message="Edit your workflow to adjust the voice agent's behavior, add new steps, or modify the conversation flow."
|
||||
onDismiss={() => markTooltipSeen('customize_workflow')}
|
||||
showNext={false}
|
||||
isVisible={!hasSeenTooltip('customize_workflow')}
|
||||
/>
|
||||
)}
|
||||
</WorkflowLayout>
|
||||
|
|
|
|||
|
|
@ -1,92 +1,121 @@
|
|||
'use client';
|
||||
|
||||
import { arrow, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { type TooltipKey, useOnboarding } from '@/context/OnboardingContext';
|
||||
|
||||
interface OnboardingTooltipProps {
|
||||
/** Onboarding flag this tooltip is keyed to. Visibility ("not seen yet")
|
||||
* and dismissal (mark seen, including when the target itself is clicked)
|
||||
* are derived from it, so call sites don't wire up the onboarding
|
||||
* context themselves. */
|
||||
tooltipKey: TooltipKey;
|
||||
targetRef: React.RefObject<HTMLElement | HTMLButtonElement | null>;
|
||||
title?: string;
|
||||
message: string;
|
||||
onDismiss: () => void;
|
||||
/** Extra gating beyond "not seen yet" (e.g. panel open, data loaded). */
|
||||
enabled?: boolean;
|
||||
onNext?: () => void;
|
||||
showNext?: boolean;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingTooltip = ({
|
||||
tooltipKey,
|
||||
targetRef,
|
||||
title = "One more thing...",
|
||||
title = 'One more thing...',
|
||||
message,
|
||||
onDismiss,
|
||||
enabled = true,
|
||||
onNext,
|
||||
showNext = true,
|
||||
isVisible
|
||||
}: OnboardingTooltipProps) => {
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
|
||||
const arrowRef = useRef<HTMLDivElement>(null);
|
||||
const messageId = useId();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const isVisible = enabled && !hasSeenTooltip(tooltipKey);
|
||||
const dismiss = useCallback(() => markTooltipSeen(tooltipKey), [markTooltipSeen, tooltipKey]);
|
||||
|
||||
const { refs, floatingStyles, middlewareData, placement, isPositioned, elements } = useFloating({
|
||||
placement: 'bottom',
|
||||
strategy: 'fixed',
|
||||
open: isVisible,
|
||||
// Tracks the target through scrolling (including nested overflow
|
||||
// containers), resizes, and layout shifts.
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ padding: 16 }),
|
||||
shift({ padding: 16 }),
|
||||
arrow({ element: arrowRef }),
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
// Adopt the target as the floating reference on every render: a ref prop
|
||||
// can't trigger effects when its element mounts late or remounts, and
|
||||
// setReference bails out when the element is unchanged.
|
||||
useEffect(() => {
|
||||
if (!isVisible || !targetRef.current) return;
|
||||
refs.setReference(targetRef.current);
|
||||
});
|
||||
|
||||
const calculatePosition = () => {
|
||||
if (!targetRef.current || !tooltipRef.current) return;
|
||||
|
||||
const targetRect = targetRef.current.getBoundingClientRect();
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
|
||||
// Position tooltip below the target element with some offset for the arrow
|
||||
const top = targetRect.bottom + 8; // 8px gap for arrow
|
||||
|
||||
// Center the tooltip horizontally relative to the target
|
||||
let left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
|
||||
|
||||
// Ensure tooltip doesn't go off-screen
|
||||
const padding = 16;
|
||||
if (left < padding) {
|
||||
left = padding;
|
||||
} else if (left + tooltipRect.width > window.innerWidth - padding) {
|
||||
left = window.innerWidth - tooltipRect.width - padding;
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
};
|
||||
|
||||
// Small delay to ensure tooltip is rendered before calculating position
|
||||
const timer = setTimeout(() => {
|
||||
calculatePosition();
|
||||
}, 10);
|
||||
|
||||
// Recalculate on window resize
|
||||
window.addEventListener('resize', calculatePosition);
|
||||
window.addEventListener('scroll', calculatePosition);
|
||||
// While pointing at the target: pulsate it, link it to the message for
|
||||
// screen readers, and treat a click on it as "seen".
|
||||
useEffect(() => {
|
||||
const target = elements.reference;
|
||||
if (!isVisible || !(target instanceof HTMLElement)) return;
|
||||
|
||||
target.classList.add('onboarding-pulse');
|
||||
target.setAttribute('aria-describedby', messageId);
|
||||
target.addEventListener('click', dismiss);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('resize', calculatePosition);
|
||||
window.removeEventListener('scroll', calculatePosition);
|
||||
target.classList.remove('onboarding-pulse');
|
||||
target.removeAttribute('aria-describedby');
|
||||
target.removeEventListener('click', dismiss);
|
||||
};
|
||||
}, [isVisible, targetRef]);
|
||||
}, [isVisible, elements.reference, dismiss, messageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') dismiss();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [isVisible, dismiss]);
|
||||
|
||||
if (!mounted || !isVisible) return null;
|
||||
|
||||
// Actual side after flip(): 'bottom' (below target) or 'top' (above).
|
||||
const side = placement.split('-')[0] === 'top' ? 'top' : 'bottom';
|
||||
|
||||
const tooltipContent = (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="fixed z-[100] animate-in fade-in slide-in-from-top-2 duration-300"
|
||||
style={{ top: `${position.top}px`, left: `${position.left}px` }}
|
||||
ref={refs.setFloating}
|
||||
className={`z-[100] animate-in fade-in duration-300 ${side === 'bottom' ? 'slide-in-from-top-2' : 'slide-in-from-bottom-2'}`}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
// Avoid a flash at (0,0) before the first position resolves.
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Arrow pointing up */}
|
||||
{/* Arrow pointing at the target; floating-ui keeps it aligned even
|
||||
when the tooltip body is shifted to stay on-screen. */}
|
||||
<div
|
||||
className="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-blue-500 rotate-45"
|
||||
ref={arrowRef}
|
||||
className="absolute h-4 w-4 rotate-45 bg-blue-500"
|
||||
style={{
|
||||
boxShadow: '-2px -2px 4px rgba(0, 0, 0, 0.1)'
|
||||
left: middlewareData.arrow?.x != null ? `${middlewareData.arrow.x}px` : undefined,
|
||||
...(side === 'bottom' ? { top: '-8px' } : { bottom: '-8px' }),
|
||||
boxShadow: '-2px -2px 4px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -94,7 +123,7 @@ export const OnboardingTooltip = ({
|
|||
<div className="relative bg-blue-500 text-white rounded-lg shadow-2xl p-6 max-w-sm">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
onClick={dismiss}
|
||||
className="absolute top-2 right-2 p-1 hover:bg-blue-600 rounded-full transition-colors"
|
||||
aria-label="Close tooltip"
|
||||
>
|
||||
|
|
@ -105,14 +134,14 @@ export const OnboardingTooltip = ({
|
|||
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-sm leading-relaxed mb-4 pr-4">
|
||||
<p id={messageId} className="text-sm leading-relaxed mb-4 pr-4">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
onClick={dismiss}
|
||||
className="bg-white text-blue-500 px-4 py-1.5 rounded font-medium text-sm hover:bg-blue-50 transition-colors cursor-pointer"
|
||||
>
|
||||
Close
|
||||
|
|
@ -122,7 +151,7 @@ export const OnboardingTooltip = ({
|
|||
<button
|
||||
onClick={() => {
|
||||
onNext?.();
|
||||
onDismiss();
|
||||
dismiss();
|
||||
}}
|
||||
className="bg-white text-blue-500 px-4 py-1.5 rounded font-medium text-sm hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue