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}
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user