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
+20
View File
@@ -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}
@@ -146,6 +146,48 @@ describe('POST /api/user/ui-state', () => {
expect(body.data.ui_state).toEqual({ nav_collapsed: true })
})
it('returns 400 when a trial_expired_ack key is not a company UUID', async () => {
const res = await POST(request({ trial_expired_ack: { 'not-a-uuid': new Date().toISOString() } }))
expect(res.status).toBe(400)
})
it('returns 400 when a trial_expired_ack value is not an ISO timestamp', async () => {
const res = await POST(
request({ trial_expired_ack: { '11111111-1111-4111-8111-111111111111': 'yesterday' } }),
)
expect(res.status).toBe(400)
})
it('merges trial_expired_ack per company instead of replacing the record', async () => {
const ackedAt = '2026-08-01T10:00:00.000Z'
enqueue({
data: {
ui_state: {
trial_expired_ack: { '11111111-1111-4111-8111-111111111111': ackedAt },
},
},
})
enqueue({ data: null })
const newAck = '2026-08-19T09:00:00.000Z'
const { status, body } = await parseJsonResponse<{
data: { ui_state: { trial_expired_ack: Record<string, string> } }
}>(
await POST(
request({
trial_expired_ack: { '22222222-2222-4222-8222-222222222222': newAck },
}),
),
)
expect(status).toBe(200)
// Acking company B never clears company A's ack.
expect(body.data.ui_state.trial_expired_ack).toEqual({
'11111111-1111-4111-8111-111111111111': ackedAt,
'22222222-2222-4222-8222-222222222222': newAck,
})
})
it('returns 500 when the upsert fails', async () => {
enqueue({ data: null })
enqueue({ data: null, error: { message: 'boom' } })
+9
View File
@@ -35,6 +35,12 @@ const BodySchema = z
})
.strict()
.optional(),
// One-time expired-trial dialog acknowledgement: companyId -> ISO
// timestamp of the ack. Merged per key like create_mode, so acking one
// company never clears another's.
trial_expired_ack: z
.record(z.string().uuid(), z.string().datetime())
.optional(),
})
.strict()
@@ -79,6 +85,9 @@ export async function POST(request: Request) {
...(patch.agent_panel
? { agent_panel: { ...current.agent_panel, ...patch.agent_panel } }
: {}),
...(patch.trial_expired_ack
? { trial_expired_ack: { ...current.trial_expired_ack, ...patch.trial_expired_ack } }
: {}),
}
const { error: upsertError } = await supabase