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
@@ -0,0 +1,121 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { ChevronRight, Clock, Lock } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
/**
* The chrome-level subscription 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.
*
* trial -> the countdown pill ("Provperiod: N dagar kvar").
* trial_expired /
* lapsed_subscription -> a muted pill linking to /settings/billing. This is
* the navigation affordance a lapsed user could not
* find (2026-08-18 report), so it is NOT dismissable
* and, unlike the countdown, stays visible when the
* sidebar is collapsed (icon-only with aria-label).
*
* Hidden for sandbox/demo (no checkout; sandbox companies carry trial grants
* too) and for paying companies. Muted chrome tone throughout: status colors
* are data, never chrome.
*/
export function SubscriptionTouchpoint({
variant,
collapsed = false,
onNavigate,
}: {
variant: 'sidebar' | 'mobile'
collapsed?: boolean
onNavigate?: () => void
}) {
const { entitlementState, trialEndsAt, isSandbox } = useCompany()
const tNav = useTranslations('nav')
// Trial countdown. 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.
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])
if (isSandbox) return null
const lapsed =
entitlementState === 'trial_expired' || entitlementState === 'lapsed_subscription'
const showCountdown =
entitlementState === 'trial' && trialDaysLeft !== null
if (!lapsed && !showCountdown) return null
const label = lapsed
? tNav(
entitlementState === 'lapsed_subscription'
? 'subscription_lapsed_cta'
: 'trial_expired_cta',
)
: tNav('trial_days_left', { days: trialDaysLeft ?? 0 })
const Icon = lapsed ? Lock : Clock
if (variant === 'mobile') {
return (
<Link
href="/settings/billing"
onClick={onNavigate}
className="flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-foreground transition-colors active:bg-muted/60"
>
<Icon className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
<span className="text-sm flex-1">{label}</span>
<ChevronRight className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
</Link>
)
}
// Sidebar. The countdown keeps its pre-existing behavior of hiding when the
// rail is collapsed; the lapsed CTA must not vanish, so it collapses to an
// icon-only link instead.
if (collapsed) {
if (!lapsed) return null
return (
<div className="flex flex-shrink-0 justify-center pb-2">
<Link
href="/settings/billing"
title={label}
aria-label={label}
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
>
<Icon className="h-[17px] w-[17px]" />
</Link>
</div>
)
}
return (
<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"
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{label}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
</Link>
</div>
)
}
export default SubscriptionTouchpoint
+92
View File
@@ -0,0 +1,92 @@
'use client'
import { useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { persistUiState } from '@/lib/ui-state/client'
import { useFormat } from '@/lib/hooks/use-format'
import type { EntitlementState } from '@/lib/entitlements/has-capability'
/**
* One-time on-entry notice that the trial (or a cancelled subscription) has
* lapsed, with the path to Abonnemang. Shown once per user AND company:
* the acknowledgement persists in user_preferences.ui_state
* (trial_expired_ack[companyId]), read server-side by the dashboard layout so
* an acked dialog never mounts (no flash). Both dismissing and clicking
* through count as acknowledged; afterwards the persistent
* SubscriptionTouchpoint in the chrome carries the CTA. The layout gates
* sandbox/anonymous users (no billing); this component additionally skips
* /settings/* so it never stacks on the routed settings modal.
*/
export function TrialExpiredDialog({
state,
trialExpiredAt,
companyId,
initialAcknowledged,
}: {
state: EntitlementState
trialExpiredAt: string | null
companyId: string
initialAcknowledged: boolean
}) {
const t = useTranslations('trial_expired_dialog')
const { formatDateLong } = useFormat()
const router = useRouter()
const pathname = usePathname()
const [open, setOpen] = useState(true)
const lapsed = state === 'trial_expired' || state === 'lapsed_subscription'
if (!lapsed || initialAcknowledged || pathname.startsWith('/settings')) {
return null
}
const acknowledge = () => {
setOpen(false)
persistUiState({
trial_expired_ack: { [companyId]: new Date().toISOString() },
})
}
const goToBilling = () => {
acknowledge()
router.push('/settings/billing')
}
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) acknowledge() }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="font-display text-xl tracking-tight">
{state === 'lapsed_subscription'
? t('title_lapsed')
: trialExpiredAt
? t('title_dated', { date: formatDateLong(trialExpiredAt) })
: t('title')}
</DialogTitle>
<DialogDescription className="sr-only">{t('body_paused')}</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('body_paused')}</p>
<p>{t('body_free')}</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={acknowledge}>
{t('cta_secondary')}
</Button>
<Button onClick={goToBilling}>{t('cta_primary')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default TrialExpiredDialog
+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 },