Files
accounted/contexts/CompanyContext.tsx
T
c402421908 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>
2026-08-20 10:05:39 +02:00

66 lines
2.1 KiB
TypeScript

'use client'
import { createContext, useContext } from 'react'
import type { Company, CompanyRole, Team } from '@/types'
import type { CapabilityKey } from '@/lib/entitlements/keys'
import type { EntitlementState } from '@/lib/entitlements/has-capability'
interface CompanyContextValue {
company: Company | null
role: CompanyRole | null
companies: { company: Company; role: CompanyRole }[]
isTeamMember: boolean
team: Team | null
isSandbox: boolean
/** PAID capability keys the active company currently holds (entitled + enabled). */
capabilities: CapabilityKey[]
/**
* Trial expiry while the trial is the company's only source of paid access;
* null when paying/comped or after the trial lapsed. Drives the countdown
* touchpoint in the sidebar.
*/
trialEndsAt: string | null
/**
* Paid-lifecycle state (trial / trial_expired / lapsed_subscription /
* paid / none). 'none' in the company:null shells. Drives the expired-trial
* touchpoint and dialog.
*/
entitlementState: EntitlementState
/** When the lapsed trial ran out; set only while entitlementState is 'trial_expired'. */
trialExpiredAt: string | null
}
const CompanyContext = createContext<CompanyContextValue | null>(null)
export function CompanyProvider({
children,
value,
}: {
children: React.ReactNode
value: CompanyContextValue
}) {
return <CompanyContext.Provider value={value}>{children}</CompanyContext.Provider>
}
export function useCompany() {
const ctx = useContext(CompanyContext)
if (!ctx) throw new Error('useCompany must be used within CompanyProvider')
return ctx
}
export function useCompanyOptional() {
return useContext(CompanyContext)
}
/**
* Whether the active company holds a given paid capability. Controls UI
* affordances only: the server gate (lib/entitlements) is the real enforcement.
* Fail-open when rendered outside a CompanyProvider (e.g. standalone dialogs):
* the server still blocks; this only decides whether to show/disable/upsell.
*/
export function useCapability(key: CapabilityKey): boolean {
const ctx = useContext(CompanyContext)
if (!ctx) return true
return ctx.capabilities.includes(key)
}