Sasquatch: restore the pulse, hide by dragging left, cleaner Go Pro

- Restore the always-on blink/pulse (still motion-reduce:hidden for a11y).
- Drag the bubble off the left edge to hide it; a tiny left-edge tab brings it
  back. (The ✕/panel/Settings Hide paths remain.)
- Replaced the cramped inline "Go Pro" header pill with a clean full-width
  strip at the top of the panel body.
This commit is contained in:
SkateSpots 2026-10-04 17:54:42 +07:00
parent 0e405eaa00
commit d82ecf4adf
3 changed files with 54 additions and 25 deletions

View file

@ -1136,7 +1136,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
<LazySasquatchGuide
message={currentMessage}
onDismiss={dismissCurrentMessage}
isFullBleed={isFullBleed}
/>
</Suspense>

View file

@ -117,8 +117,6 @@ export interface GuideMessage {
interface SasquatchGuideProps {
message?: GuideMessage;
onDismiss?: () => void;
/** True on full-bleed routes (the map) — the bubble runs compact/no-pulse. */
isFullBleed?: boolean;
}
interface ChatMessage {
@ -661,7 +659,7 @@ function ChatText({ text }: { text: string }) {
// ─── Main component ───────────────────────────────────────────────────────────
export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGuideProps) {
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const { user } = useCurrentUser();
const { t, language } = useTranslation();
const cvm = useCVMClient();
@ -693,6 +691,9 @@ export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGui
// Draggable position state for minimized bubble
const [dragPos, setDragPos] = useLocalStorage<{ x: number; y: number } | null>('sasquatch-pos', null);
const [isDragging, setIsDragging] = useState(false);
// Mirror of dragPos for the pointer-up handler (state may be stale in closures).
const dragPosRef = useRef(dragPos);
useEffect(() => { dragPosRef.current = dragPos; }, [dragPos]);
const dragRef = useRef<HTMLDivElement>(null);
const dragStart = useRef<{ mx: number; my: number; bx: number; by: number } | null>(null);
const dragMoved = useRef(false);
@ -741,6 +742,15 @@ export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGui
dragStart.current = null;
dragMoved.current = false;
setTimeout(() => setIsDragging(false), 50);
// Drag it off the left edge to hide it — a tiny tab brings it back.
if (wasDrag) {
const x = dragPosRef.current?.x;
if (x != null && x <= 28) {
setHidden(true);
setDragPos(null);
return;
}
}
// If this pointer sequence was a genuine tap (no real drag), open here.
// A parallel onClick handler also opens as a reliable fallback for mobile
// WebViews where the pointer-capture tap can be flaky on first interaction;
@ -748,7 +758,7 @@ export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGui
if (!wasDrag) {
openPanel();
}
}, [openPanel]);
}, [openPanel, setHidden, setDragPos]);
// Reliable open path: plain click. On mobile WebViews the pointer-capture tap
// detection can miss the very first interaction after mount (the "1st tap does
@ -1549,10 +1559,8 @@ Instructions:
// opt-in auto-show message (e.g. onboarding) — never automatically on / or /lab.
const shouldShowPanel = isVisible;
// A proactive tip is waiting (drives the pulse/dot), and the map route runs
// the bubble in a quieter, compact form.
// A proactive tip is waiting (drives the unread dot).
const hasUnread = !!message;
const compact = !!isFullBleed;
// Notify the rest of the app (e.g. MapPage FAB) when the panel opens or closes
useEffect(() => {
@ -1583,12 +1591,26 @@ Instructions:
enableChat: true,
};
if (hidden) return null;
// Hidden by dragging to the left edge (or via the ✕ / panel Hide button). A
// tiny edge tab brings it back onto the screen.
if (hidden) {
return (
<button
type="button"
aria-label={t('sasquatchUi.showAria')}
title={t('sasquatchUi.showAria')}
onClick={() => setHidden(false)}
className="fixed left-0 top-1/2 z-50 -translate-y-1/2 rounded-r-full border border-l-0 border-[#ccff00]/40 bg-black/70 py-3 pl-0.5 pr-1.5 transition-colors hover:bg-black/90"
>
<span className="block h-5 w-1.5 rounded-full bg-[#ccff00]/70" />
</button>
);
}
return (
<>
{/* ── Minimized bubble — visible unless the user hid the assistant ── */}
{!hidden && (!shouldShowPanel || isMinimized) && (
{/* ── Minimized bubble — always visible, draggable ── */}
{(!shouldShowPanel || isMinimized) && (
<div
ref={dragRef}
className={cn(
@ -1621,11 +1643,9 @@ Instructions:
alt="The Sasquatch"
className="w-8 h-8 md:w-9 md:h-9 rounded-full object-cover pointer-events-none"
/>
{/* Pulse only when there's an unread tip, never on the map, and never
under prefers-reduced-motion (the motion is the nuisance). */}
{hasUnread && !compact && (
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00] motion-reduce:hidden" />
)}
{/* Pulse like the original — always on, except under
prefers-reduced-motion. */}
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00] motion-reduce:hidden" />
{hasUnread && (
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" />
)}
@ -1702,15 +1722,6 @@ Instructions:
>
⚡ Pro{proDaysLeft !== null ? ` · ${proDaysLeft}d` : ''}
</span>
) : user ? (
<button
onClick={() => setProDialogOpen(true)}
className="ml-0.5 inline-flex items-center gap-1 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-wider text-[#ccff00] transition-colors hover:border-[#ccff00]/70 hover:bg-[#ccff00]/20"
title={t('sasquatchUi.upgradeTitle')}
>
<span aria-hidden>⚡</span>
{t('sasquatchUi.goPro')}
</button>
) : null}
</div>
{/* Context + (owner-only) AI balance on one tidy line */}
@ -1765,6 +1776,21 @@ Instructions:
{/* Body — scrolls internally so the card never outgrows the viewport */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3">
{/* Go Pro — a clean full-width strip (the inline header pill looked
cramped). Only for signed-in non-Pro users. */}
{!isPro && user && (
<button
onClick={() => setProDialogOpen(true)}
className="mb-3 flex w-full items-center gap-2 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/10 px-3 py-2 text-left transition-colors hover:border-[#ccff00]/60 hover:bg-[#ccff00]/15"
title={t('sasquatchUi.upgradeTitle')}
>
<span aria-hidden className="text-sm">⚡</span>
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-black uppercase tracking-wider text-[#ccff00]">{t('sasquatchUi.goPro')}</span>
<span className="block truncate text-[10px] text-[#ccff00]/60">{t('sasquatchUi.goProBlurb')}</span>
</span>
</button>
)}
{/* Greeting — only shown before the user starts chatting, so the
same wall of welcome text doesn't reappear on every open once
a conversation is underway. */}

View file

@ -31,10 +31,12 @@ const sasquatchUi = {
openGuideAria: 'Open Sasquatch guide',
hideAria: 'Hide the Sasquatch assistant',
hide: 'Hide assistant',
showAria: 'Show the Sasquatch assistant',
activeUntil: 'Active until {{date}}',
platformOwner: 'Platform owner',
upgradeTitle: 'Upgrade to Sasquatch Pro',
goPro: 'Go Pro',
goProBlurb: 'Personal AI · from 2k sats',
aiBalanceTitle: 'Sasquatch AI credit balance (owner-only) — top up at {{provider}}',
aiChip: '⚡ AI {{balance}}',
minimizeToBubbleAria: 'Minimize to bubble',
@ -164,10 +166,12 @@ const sasquatchUi = {
openGuideAria: 'Abrir la guía del Sasquatch',
hideAria: 'Ocultar el asistente Sasquatch',
hide: 'Ocultar asistente',
showAria: 'Mostrar el asistente Sasquatch',
activeUntil: 'Activo hasta el {{date}}',
platformOwner: 'Propietario de la plataforma',
upgradeTitle: 'Mejora a Sasquatch Pro',
goPro: 'Hazte Pro',
goProBlurb: 'IA personal · desde 2k sats',
aiBalanceTitle: 'Saldo de crédito de la IA del Sasquatch (solo propietario) — recarga en {{provider}}',
aiChip: '⚡ IA {{balance}}',
minimizeToBubbleAria: 'Minimizar a la burbuja',