feat(billing): make the expired-trial state visible with a clear upgrade path (#1725)

getCompanyEntitlements now derives an entitlementState (trial /
trial_expired / lapsed_subscription / paid / none) plus trialExpiredAt
from the grants it already fetches, reading company_subscriptions.status
inside the existing Promise.all so churned payers get 'abonnemang' copy
instead of 'provperiod'. The state threads through CompanyContext and the
dashboard layout.

Two new surfaces, both hidden in sandbox:
- SubscriptionTouchpoint replaces the sidebar trial pill: countdown while
  the trial runs, a persistent muted upgrade link to /settings/billing
  once it lapses (visible even collapsed, icon-only with aria-label), and
  the first mobile bottom-sheet touchpoint.
- TrialExpiredDialog: one-time on-entry notice with 'Se abonnemang' and a
  ghost dismiss; acknowledgement persists per user+company in
  user_preferences.ui_state.trial_expired_ack (read server-side, no
  flash), set on dismiss and click-through alike.

Narrows the 2026-07-11 'no trial-expired nag' decision at the founder's
direction after a user could not find the upgrade path at all; see
DECISIONS.md.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 10:05:39 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 834cc4d0e8
commit c402421908
13 changed files with 476 additions and 62 deletions
+10 -43
View File
@@ -32,7 +32,6 @@ import {
Tag,
Tags,
ChevronRight,
Clock,
Sparkles,
Percent,
Landmark,
@@ -55,6 +54,7 @@ import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
import UserMenu from '@/components/dashboard/UserMenu'
import SubscriptionTouchpoint from '@/components/billing/SubscriptionTouchpoint'
import AgentAvatar from '@/components/agent/AgentAvatar'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext'
@@ -298,7 +298,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const pathname = usePathname()
const router = useRouter()
const supabase = useRealtimeSupabase()
const { company, capabilities, trialEndsAt } = useCompany()
const { company, capabilities } = useCompany()
// Agent identity drives the "Assistent" nav icon: when the user has
// built their assistant we show its chosen avatar instead of the
// generic Sparkles glyph.
@@ -317,28 +317,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
pendingOperations: pendingOpsCount,
refresh: refreshBadges,
} = useWorklistBadges(company?.id)
// Trial countdown for the sidebar touchpoint. Computed in an effect (not
// during render) so server and client markup agree at hydration; an hourly
// tick keeps a long-lived tab from showing yesterday's count. The sync
// setState is that hydration strategy, not derived-state-in-effect (the
// lint only started analyzing this component once the badge-refresh loop
// that made the compiler bail was removed).
const [trialDaysLeft, setTrialDaysLeft] = useState<number | null>(null)
useEffect(() => {
if (!trialEndsAt) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTrialDaysLeft(null)
return
}
const update = () => {
const msLeft = new Date(trialEndsAt).getTime() - Date.now()
setTrialDaysLeft(msLeft > 0 ? Math.ceil(msLeft / 86_400_000) : null)
}
update()
const id = setInterval(update, 3_600_000)
return () => clearInterval(id)
}, [trialEndsAt])
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
@@ -893,25 +871,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</nav>
</div>
{/* Trial countdown touchpoint: the paywall is a lifecycle flow, not
a settings page, so trial state stays quietly visible in the
chrome instead of only inside Inställningar → Abonnemang.
Hidden for sandbox/demo (no checkout) and once any non-trial
grant is active (trialEndsAt is null then). */}
{!collapsed && !isSandbox && trialDaysLeft !== null && (
<div className="flex-shrink-0 px-3 pb-2">
<Link
href="/settings/billing"
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
>
<Clock className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{tNav('trial_days_left', { days: trialDaysLeft })}
</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
</Link>
</div>
)}
{/* Subscription touchpoint: trial countdown while the trial runs,
a persistent muted upgrade link once it (or a subscription) has
lapsed. Hides itself for sandbox/demo and paying companies. */}
<SubscriptionTouchpoint variant="sidebar" collapsed={collapsed} />
{/* Sticky user block (bottom-left): avatar, name, active company.
Opens the upward user menu with the company-switcher flyout,
@@ -1206,6 +1169,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</div>
<div className="space-y-0.5">
{/* Subscription touchpoint: mobile had no trial surface at
all before this row (trial countdown or lapsed upgrade
link; hides itself for sandbox and paying companies). */}
<SubscriptionTouchpoint variant="mobile" onNavigate={closeMobileMenu} />
{([
{ href: '/settings', labelKey: 'settings' as NavLabelKey, icon: Settings },
{ href: '/help', labelKey: 'help' as NavLabelKey, icon: HelpCircle },