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
@@ -11,6 +11,7 @@ import LazyCommandPalette from '@/components/common/LazyCommandPalette'
|
||||
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
|
||||
import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController'
|
||||
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
||||
import TrialExpiredDialog from '@/components/billing/TrialExpiredDialog'
|
||||
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
||||
import { CompanyProvider } from '@/contexts/CompanyContext'
|
||||
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
|
||||
@@ -107,6 +108,8 @@ export default async function DashboardLayout({
|
||||
isSandbox: false,
|
||||
capabilities: [],
|
||||
trialEndsAt: null,
|
||||
entitlementState: 'none' as const,
|
||||
trialExpiredAt: null,
|
||||
}}
|
||||
>
|
||||
<SessionTimeoutController />
|
||||
@@ -231,6 +234,8 @@ export default async function DashboardLayout({
|
||||
isSandbox: false,
|
||||
capabilities: [],
|
||||
trialEndsAt: null,
|
||||
entitlementState: 'none' as const,
|
||||
trialExpiredAt: null,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -314,6 +319,8 @@ export default async function DashboardLayout({
|
||||
isSandbox,
|
||||
capabilities: entitlements.capabilities,
|
||||
trialEndsAt: entitlements.trialEndsAt,
|
||||
entitlementState: entitlements.entitlementState,
|
||||
trialExpiredAt: entitlements.trialExpiredAt,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -361,6 +368,19 @@ export default async function DashboardLayout({
|
||||
<MainContainer companyId={companyId}>{children}</MainContainer>
|
||||
</main>
|
||||
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
|
||||
{/* One-time expired-trial notice. Sandbox/anonymous demo users have
|
||||
no billing (their companies carry trial grants too), so the gate
|
||||
lives here where both flags are known. Acknowledgement persists
|
||||
per user AND company in user_preferences.ui_state, read here
|
||||
server-side so an acked dialog never flashes. */}
|
||||
{!isSandbox && !user.is_anonymous && (
|
||||
<TrialExpiredDialog
|
||||
state={entitlements.entitlementState}
|
||||
trialExpiredAt={entitlements.trialExpiredAt}
|
||||
companyId={companyId}
|
||||
initialAcknowledged={!!uiState.trial_expired_ack?.[companyId]}
|
||||
/>
|
||||
)}
|
||||
<LazyCommandPalette />
|
||||
<SettingsHotkey />
|
||||
{settingsModal}
|
||||
|
||||
Reference in New Issue
Block a user