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
@@ -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