From 622b144a3d4e630bfb4c7571b7be669c74e7497f Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:40:58 +0200 Subject: [PATCH] fix(agent): let non-payers dismiss the upsell FAB for the session (#1475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user without the AI capability could not get rid of the floating "Uppgradera för att använda {namn}" pill: it had no dismiss of its own, and closing the paywalled agent sheet just brought it back, leaving a wide overlay pinned in the bottom-right corner (reported by a user via Discord). The pill now carries an X segment (non-payer, fresh state only) and a non-payer closing the agent sheet counts as the same dismissal. Both hide all floating assistant UI for the rest of the browser session via sessionStorage; a new session shows the pill full-size again, so the conversion surface is muted per session, never silenced permanently. Payer behavior and the collapsed-session handle (the only way back to a minimized conversation) are unchanged. Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + components/agent/AgentTrigger.tsx | 127 ++++++++++++++++++++++++------ 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 4d951228..333aa9d0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -846,3 +846,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-08] Shopify order feed queries zero PII fields (no customer/email/addresses): keeps the integration entirely outside Shopify's protected-customer-data program and the GDPR RoPA small; BFL verifikat reference needs only the order name/id. Qualification is `displayFinancialStatus` in `PAID`/`PARTIALLY_REFUNDED`/`REFUNDED` and `test=false`, row date is `processedAt`; no MCP tools (same precedent as `bank_sync`/woocommerce: feeds are cron/HTTP only, the agent path is the shopify-to-gnubok skill). [2026-08-08] extensions.schema.json enum also gained "stripe" while adding "shopify": the enum had drifted (stripe was enabled in extensions.config.json but missing from the schema, failing editor validation); fixed in the same touch since the file had to change anyway. [2026-08-08] Login panel is method-stated (BankID hero default, remembered via accounted-login-method cookie) instead of a stacked method list: matches the Swedish bank/Fortnox convention, gives exactly one primary action per view; errors moved from boxed banner to a field-adjacent single line (NN/g 3/4/10), reset link surfaces from the second failed attempt. +[2026-08-09] Upsell FAB dismissal is session-scoped (sessionStorage), not persisted: closing the paywalled sheet or the pill's X hides all floating assistant UI for non-payers until the next browser session; a permanent dismissal would let one click silence the conversion surface forever, and payer/collapsed FAB behavior stays untouched. diff --git a/components/agent/AgentTrigger.tsx b/components/agent/AgentTrigger.tsx index d70513b2..e8acf569 100644 --- a/components/agent/AgentTrigger.tsx +++ b/components/agent/AgentTrigger.tsx @@ -1,8 +1,9 @@ 'use client' +import { useEffect, useRef, useState } from 'react' import { useAgentSheet } from './AgentSheetProvider' import { usePathname, useRouter } from 'next/navigation' -import { Loader2 } from 'lucide-react' +import { Loader2, X } from 'lucide-react' import AgentAvatar from './AgentAvatar' import { collapsedStatusLabel } from './agent-status' import { routeToIntent } from '@/lib/agent/intents/route-mapping' @@ -35,18 +36,74 @@ import { CAPABILITY } from '@/lib/entitlements/keys' // row-level "Fråga [namn]" button in TransactionInboxCard, and the matching // "Fråga assistenten" in Dokumentinkorgen: both passing a transaction_id the // pathname-only FAB can't know.) +// Session-scoped dismissal flag for the non-payer upsell pill. sessionStorage +// on purpose: "close" means gone for THIS browser session, and the pill comes +// back full-size next session. Anything permanent (localStorage or +// user_preferences) would let a non-payer silence the conversion surface +// forever with one click (founder call 2026-08-09). +const UPSELL_DISMISSED_KEY = 'agent-upsell-dismissed' + export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) { const { openAgentSheet, expandAgentSheet, isOpen, collapsed, status, identity } = useAgentSheet() const pathname = usePathname() const router = useRouter() const hasAi = useCapability(CAPABILITY.ai) + // Read the dismissal AFTER hydration (effect, not state initializer): the + // server always renders the pill, so an initializer that reads + // sessionStorage would mismatch the SSR HTML. Costs one frame of pill + // before it hides, which is invisible in practice. + const [upsellDismissed, setUpsellDismissed] = useState(false) + useEffect(() => { + try { + if (window.sessionStorage.getItem(UPSELL_DISMISSED_KEY) === '1') setUpsellDismissed(true) + } catch { + // Storage unavailable (private mode, blocked cookies): the pill simply + // stays dismissible per page load instead of per session. + } + }, []) + + const dismissUpsellForSession = () => { + setUpsellDismissed(true) + try { + window.sessionStorage.setItem(UPSELL_DISMISSED_KEY, '1') + } catch { + // Same as above: in-memory state still hides it for this page. + } + } + + // For a non-payer the sheet is a paywall surface, so closing it means "not + // now": treat it exactly like dismissing the pill. Otherwise the user closes + // the big panel and the wide "Uppgradera för att använda ..." pill pops + // right back, which is the residual-overlay complaint this fixes. Collapse + // (Minimera) is not a close and never lands here: isOpen stays true. + const prevOpenRef = useRef(isOpen) + useEffect(() => { + const wasOpen = prevOpenRef.current + prevOpenRef.current = isOpen + if (wasOpen && !isOpen && !hasAi) { + setUpsellDismissed(true) + try { + window.sessionStorage.setItem(UPSELL_DISMISSED_KEY, '1') + } catch { + // In-memory dismissal still applies. + } + } + }, [isOpen, hasAi]) + // User opt-out (Inställningar → Assistenten): the sidebar entry stays, the // floating button goes. A collapsed session keeps its reopen handle even // when hidden: it's the only way back to a minimized conversation, and its // existence implies the user is actively using the assistant right now. if (hidden && !collapsed) return null + // Dismissed upsell (non-payer clicked the pill's X, or closed the paywalled + // sheet): render nothing at all for the rest of the browser session. A + // collapsed session still shows its handle even then: it is the only way + // back to the minimized conversation, and its label is a resume action + // ("Fortsätt med ..."), not the upsell. + if (!hasAi && upsellDismissed && !collapsed) return null + // Sheet open AND visible → hide the FAB so the icon doesn't double up. When // the session is merely collapsed we KEEP the FAB: it's the handle that // brings the minimized conversation back. @@ -147,36 +204,58 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) { // no resize listener, no layout shift on first paint. const visibilityClass = collapsed ? 'flex' : 'hidden md:flex' + // The upsell pill (fresh state, no capability) carries its own dismiss so a + // non-payer is never stuck with an undismissable ad in the corner. Payer and + // collapsed states are unchanged: no X there, the pill is functional UI. + const dismissible = !hasAi && !collapsed + return ( - + {dismissible && ( + )} - {labelText} - + ) }