fix(agent): let non-payers dismiss the upsell FAB for the session (#1475)

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 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-09 13:40:58 +02:00
committed by GitHub
parent c187fabf92
commit 622b144a3d
2 changed files with 104 additions and 24 deletions
+1
View File
@@ -846,3 +846,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+103 -24
View File
@@ -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 (
<button
onClick={handleClick}
// Wrapper div carries the pill shell; the two segments inside are separate
// buttons because a dismiss nested in the main <button> would be invalid
// HTML. Segment hover is a background/10 overlay, visually equivalent to
// the old whole-pill foreground/90 shift.
<div
// Mobile: sit above the bottom nav (h-16 = 64px) AND the iOS home
// indicator (env(safe-area-inset-bottom)). Still needed after the FAB
// went desktop-only: the collapsed handle above renders on mobile too.
// Desktop: standard 20px lift, no mobile nav to worry about.
className={`fixed right-4 z-30 ${visibilityClass} h-12 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full bg-foreground pl-2 pr-4 text-background shadow-lg hover:bg-foreground/90 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4`}
aria-label={labelText}
// The label changes on its own while the panel is hidden, so it has to be
// announced rather than only redrawn. Polite: it never interrupts.
aria-live="polite"
className={`fixed right-4 z-30 ${visibilityClass} h-12 max-w-[calc(100vw-2rem)] items-stretch rounded-full bg-foreground text-background shadow-lg bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4`}
>
<span className="relative shrink-0">
<AgentAvatar
avatarId={identity.avatarId}
size="sm"
className="ring-2 ring-background/20"
alt={name}
/>
{/* An answer waiting behind a hidden panel is the one thing worth a
dot: it is unread, not in progress. */}
{finished && (
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-success ring-2 ring-foreground" />
<button
onClick={handleClick}
className={`flex min-w-0 items-center gap-2 pl-2 ${dismissible ? 'rounded-l-full pr-2' : 'rounded-full pr-4'} hover:bg-background/10 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
aria-label={labelText}
// The label changes on its own while the panel is hidden, so it has to be
// announced rather than only redrawn. Polite: it never interrupts.
aria-live="polite"
>
<span className="relative shrink-0">
<AgentAvatar
avatarId={identity.avatarId}
size="sm"
className="ring-2 ring-background/20"
alt={name}
/>
{/* An answer waiting behind a hidden panel is the one thing worth a
dot: it is unread, not in progress. */}
{finished && (
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-success ring-2 ring-foreground" />
)}
</span>
{working && collapsed && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none" />
)}
</span>
{working && collapsed && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none" />
<span className="text-sm font-medium truncate">{labelText}</span>
</button>
{dismissible && (
<button
onClick={dismissUpsellForSession}
aria-label="Dölj tills nästa besök"
className="flex items-center rounded-r-full pl-1 pr-3 text-background/70 hover:text-background hover:bg-background/10 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<X className="h-4 w-4" />
</button>
)}
<span className="text-sm font-medium truncate">{labelText}</span>
</button>
</div>
)
}