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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
834cc4d0e8
commit
c402421908
@@ -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
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user