feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks
Phase 2 Task 33. Components can now ask "is this feature on?", "how many
sessions left?", and "what stage is the trial in?" without re-implementing
the read against useBillingStore.
- useFeature(flagKey): boolean — reads enabledFeatures from store
- useFeatureLimit(field): { used, limit, percentage, isAtLimit, isLoading }
with non-blocking 60s module-level cache and graceful 404 degradation
- useTrialBanner(): derives stage from subscription status + trial countdown,
returns null on initial load to prevent flicker
- usageApi.getCount(field) — calls /api/v1/usage/{field}; backend endpoint
is not yet implemented (planned), so the hook degrades to used=0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ export { default as stepsApi } from './steps'
|
|||||||
export { default as stepCategoriesApi } from './stepCategories'
|
export { default as stepCategoriesApi } from './stepCategories'
|
||||||
export { default as accountsApi } from './accounts'
|
export { default as accountsApi } from './accounts'
|
||||||
export { default as billingApi } from './billing'
|
export { default as billingApi } from './billing'
|
||||||
|
export { default as usageApi } from './usage'
|
||||||
export { default as adminApi } from './admin'
|
export { default as adminApi } from './admin'
|
||||||
export { treeMarkdownApi } from './treeMarkdown'
|
export { treeMarkdownApi } from './treeMarkdown'
|
||||||
export { default as analyticsApi } from './analytics'
|
export { default as analyticsApi } from './analytics'
|
||||||
|
|||||||
23
frontend/src/api/usage.ts
Normal file
23
frontend/src/api/usage.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage counters API.
|
||||||
|
*
|
||||||
|
* TODO: backend `/usage/{field}` endpoint not yet implemented (planned).
|
||||||
|
* Tracked under self-serve signup Phase 2 — Task 33 calls this lazily; today
|
||||||
|
* it 404s and the consuming hook (`useFeatureLimit`) cleanly degrades to
|
||||||
|
* `used = 0`.
|
||||||
|
*/
|
||||||
|
export const usageApi = {
|
||||||
|
/**
|
||||||
|
* Fetch the current count for a usage field (e.g. `active_users`,
|
||||||
|
* `flowpilot_sessions_this_month`). The field name is the same key used in
|
||||||
|
* `BillingState.planLimits`.
|
||||||
|
*/
|
||||||
|
async getCount(field: string): Promise<{ used: number }> {
|
||||||
|
const response = await apiClient.get<{ used: number }>(`/usage/${field}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usageApi
|
||||||
44
frontend/src/hooks/useFeature.test.ts
Normal file
44
frontend/src/hooks/useFeature.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useFeature } from './useFeature'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
|
||||||
|
describe('useFeature', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useBillingStore.setState({
|
||||||
|
subscription: null,
|
||||||
|
planBilling: null,
|
||||||
|
planLimits: {},
|
||||||
|
enabledFeatures: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when flag absent', () => {
|
||||||
|
const { result } = renderHook(() => useFeature('does_not_exist'))
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when flag is enabled', () => {
|
||||||
|
useBillingStore.setState({ enabledFeatures: { ai_builder: true } })
|
||||||
|
const { result } = renderHook(() => useFeature('ai_builder'))
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when flag is explicitly disabled', () => {
|
||||||
|
useBillingStore.setState({ enabledFeatures: { ai_builder: false } })
|
||||||
|
const { result } = renderHook(() => useFeature('ai_builder'))
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates when store changes (subscribes to store)', () => {
|
||||||
|
const { result } = renderHook(() => useFeature('foo'))
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useBillingStore.setState({ enabledFeatures: { foo: true } })
|
||||||
|
})
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
16
frontend/src/hooks/useFeature.ts
Normal file
16
frontend/src/hooks/useFeature.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a feature flag is enabled for the current account.
|
||||||
|
*
|
||||||
|
* Reads from `useBillingStore.enabledFeatures`, which is populated by
|
||||||
|
* `GET /billing/state`. Returns `false` when the flag is absent (closed-by-default).
|
||||||
|
*
|
||||||
|
* The hook subscribes to the store so updates from `refetch()` propagate
|
||||||
|
* without manual refetch in the component.
|
||||||
|
*/
|
||||||
|
export function useFeature(flagKey: string): boolean {
|
||||||
|
return useBillingStore((state) => Boolean(state.enabledFeatures[flagKey]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useFeature
|
||||||
112
frontend/src/hooks/useFeatureLimit.test.ts
Normal file
112
frontend/src/hooks/useFeatureLimit.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { useFeatureLimit, clearUsageCache } from './useFeatureLimit'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
|
||||||
|
vi.mock('@/api/usage', () => ({
|
||||||
|
usageApi: {
|
||||||
|
getCount: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { usageApi } from '@/api/usage'
|
||||||
|
|
||||||
|
const mockedGetCount = vi.mocked(usageApi.getCount)
|
||||||
|
|
||||||
|
describe('useFeatureLimit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearUsageCache()
|
||||||
|
mockedGetCount.mockReset()
|
||||||
|
useBillingStore.setState({
|
||||||
|
subscription: null,
|
||||||
|
planBilling: null,
|
||||||
|
planLimits: {},
|
||||||
|
enabledFeatures: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('transitions isLoading -> loaded', async () => {
|
||||||
|
useBillingStore.setState({ planLimits: { active_users: 10 } })
|
||||||
|
mockedGetCount.mockResolvedValueOnce({ used: 4 })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFeatureLimit('active_users'))
|
||||||
|
|
||||||
|
// Non-blocking initial state.
|
||||||
|
expect(result.current.isLoading).toBe(true)
|
||||||
|
expect(result.current.used).toBe(0)
|
||||||
|
expect(result.current.limit).toBe(10)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.used).toBe(4)
|
||||||
|
expect(result.current.limit).toBe(10)
|
||||||
|
expect(result.current.percentage).toBe(40)
|
||||||
|
expect(result.current.isAtLimit).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags isAtLimit when used >= limit', async () => {
|
||||||
|
useBillingStore.setState({ planLimits: { seats: 3 } })
|
||||||
|
mockedGetCount.mockResolvedValueOnce({ used: 3 })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFeatureLimit('seats'))
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||||
|
|
||||||
|
expect(result.current.isAtLimit).toBe(true)
|
||||||
|
expect(result.current.percentage).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null percentage when limit is null (unlimited)', async () => {
|
||||||
|
useBillingStore.setState({ planLimits: { sessions: null } })
|
||||||
|
mockedGetCount.mockResolvedValueOnce({ used: 7 })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFeatureLimit('sessions'))
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||||
|
|
||||||
|
expect(result.current.limit).toBe(null)
|
||||||
|
expect(result.current.percentage).toBe(null)
|
||||||
|
expect(result.current.isAtLimit).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets isLoading=true synchronously when `field` prop changes', async () => {
|
||||||
|
useBillingStore.setState({ planLimits: { max_trees: 5, max_users: 10 } })
|
||||||
|
mockedGetCount.mockResolvedValueOnce({ used: 2 }) // for max_trees
|
||||||
|
mockedGetCount.mockResolvedValueOnce({ used: 3 }) // for max_users (slow)
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ field }: { field: string }) => useFeatureLimit(field),
|
||||||
|
{ initialProps: { field: 'max_trees' } },
|
||||||
|
)
|
||||||
|
|
||||||
|
// First field resolves.
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||||
|
expect(result.current.used).toBe(2)
|
||||||
|
expect(result.current.limit).toBe(5)
|
||||||
|
|
||||||
|
// Switch field. Next render must report isLoading=true (no stale data
|
||||||
|
// bleed-through) before the new fetch resolves.
|
||||||
|
rerender({ field: 'max_users' })
|
||||||
|
expect(result.current.isLoading).toBe(true)
|
||||||
|
expect(result.current.used).toBe(0)
|
||||||
|
expect(result.current.limit).toBe(10)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||||
|
expect(result.current.used).toBe(3)
|
||||||
|
expect(result.current.limit).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('degrades to used=0 on fetch error (404 from missing endpoint)', async () => {
|
||||||
|
useBillingStore.setState({ planLimits: { active_users: 5 } })
|
||||||
|
mockedGetCount.mockRejectedValueOnce(new Error('Request failed with status 404'))
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFeatureLimit('active_users'))
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||||
|
|
||||||
|
expect(result.current.used).toBe(0)
|
||||||
|
expect(result.current.limit).toBe(5)
|
||||||
|
expect(result.current.percentage).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
125
frontend/src/hooks/useFeatureLimit.ts
Normal file
125
frontend/src/hooks/useFeatureLimit.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
import { usageApi } from '@/api/usage'
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 60 * 1000
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
used: number
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
/** Clear the usage cache (call on logout to prevent stale data across users). */
|
||||||
|
export function clearUsageCache() {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureLimitResult {
|
||||||
|
used: number
|
||||||
|
limit: number | null
|
||||||
|
/** null when limit is null (unlimited) or unknown */
|
||||||
|
percentage: number | null
|
||||||
|
isAtLimit: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceLimit(raw: unknown): number | null {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
|
||||||
|
if (raw === null || raw === undefined) return null
|
||||||
|
// The store types planLimits as Record<string, unknown>; the backend
|
||||||
|
// currently returns numbers, but defensively handle string ints too.
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns progress against a quantitative plan limit.
|
||||||
|
*
|
||||||
|
* `limit` comes from `useBillingStore.planLimits[field]`, which is read
|
||||||
|
* synchronously from the store. `used` is fetched lazily from
|
||||||
|
* `GET /api/v1/usage/{field}` on mount and cached for 60s in a module-level
|
||||||
|
* map keyed by field.
|
||||||
|
*
|
||||||
|
* Render is non-blocking: the hook returns `isLoading=true` (with `used=0`)
|
||||||
|
* until the usage fetch resolves. On 404 or any error the hook degrades to
|
||||||
|
* `used=0` with `isLoading=false` rather than surfacing the error — the
|
||||||
|
* `/usage/{field}` endpoint is not yet implemented on the backend (planned).
|
||||||
|
*/
|
||||||
|
export function useFeatureLimit(field: string): FeatureLimitResult {
|
||||||
|
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
|
||||||
|
|
||||||
|
// Initialize from cache on first mount only; subsequent `field` changes
|
||||||
|
// are handled inside the effect below so the render-phase result reflects
|
||||||
|
// the new field synchronously (no stale `used`/`isLoading` for one tick).
|
||||||
|
const initialCached = useRef<CacheEntry | undefined>(undefined)
|
||||||
|
if (initialCached.current === undefined) {
|
||||||
|
initialCached.current = cache.get(field)
|
||||||
|
}
|
||||||
|
const initialFresh =
|
||||||
|
initialCached.current && Date.now() - initialCached.current.timestamp < CACHE_TTL_MS
|
||||||
|
const [used, setUsed] = useState<number>(initialFresh ? initialCached.current!.used : 0)
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(!initialFresh)
|
||||||
|
|
||||||
|
// Track the field that the current `used`/`isLoading` state describes.
|
||||||
|
// When `field` changes, we synchronously reset state in render so callers
|
||||||
|
// never see stale data for the previous field.
|
||||||
|
const stateField = useRef<string>(field)
|
||||||
|
if (stateField.current !== field) {
|
||||||
|
stateField.current = field
|
||||||
|
const existing = cache.get(field)
|
||||||
|
const freshNow = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
|
||||||
|
if (freshNow) {
|
||||||
|
setUsed(existing!.used)
|
||||||
|
setIsLoading(false)
|
||||||
|
} else {
|
||||||
|
setUsed(0)
|
||||||
|
setIsLoading(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const existing = cache.get(field)
|
||||||
|
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
|
||||||
|
setUsed(existing.used)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setIsLoading(true)
|
||||||
|
usageApi
|
||||||
|
.getCount(field)
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return
|
||||||
|
cache.set(field, { used: result.used, timestamp: Date.now() })
|
||||||
|
setUsed(result.used)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
|
||||||
|
// 404s and other errors degrade to used=0 silently — no toast.
|
||||||
|
if (cancelled) return
|
||||||
|
setUsed(0)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [field])
|
||||||
|
|
||||||
|
const percentage =
|
||||||
|
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
|
||||||
|
const isAtLimit = limit !== null && used >= limit
|
||||||
|
|
||||||
|
return { used, limit, percentage, isAtLimit, isLoading }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useFeatureLimit
|
||||||
131
frontend/src/hooks/useTrialBanner.test.ts
Normal file
131
frontend/src/hooks/useTrialBanner.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { useTrialBanner } from './useTrialBanner'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
import type { SubscriptionState } from '@/types/billing'
|
||||||
|
|
||||||
|
const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
|
||||||
|
|
||||||
|
function makeSub(overrides: Partial<SubscriptionState>): SubscriptionState {
|
||||||
|
return {
|
||||||
|
status: 'trialing',
|
||||||
|
plan: 'starter',
|
||||||
|
current_period_start: '2026-05-01T00:00:00Z',
|
||||||
|
current_period_end: null,
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
seat_limit: null,
|
||||||
|
has_pro_entitlement: false,
|
||||||
|
is_paid: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSubscription(overrides: Partial<SubscriptionState>) {
|
||||||
|
useBillingStore.setState({ subscription: makeSub(overrides) })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTrialBanner', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(FROZEN_NOW)
|
||||||
|
useBillingStore.setState({
|
||||||
|
subscription: null,
|
||||||
|
planBilling: null,
|
||||||
|
planLimits: {},
|
||||||
|
enabledFeatures: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stage matches subscription state matrix', () => {
|
||||||
|
it('returns null when subscription is null (no flicker on initial load)', () => {
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe(null)
|
||||||
|
expect(result.current.daysRemaining).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('complimentary status -> complimentary stage', () => {
|
||||||
|
setSubscription({ status: 'complimentary' })
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('complimentary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('active status -> paid stage', () => {
|
||||||
|
setSubscription({ status: 'active' })
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('paid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('past_due status -> past_due stage', () => {
|
||||||
|
setSubscription({ status: 'past_due' })
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('past_due')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('canceled status -> canceled stage', () => {
|
||||||
|
setSubscription({ status: 'canceled' })
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('canceled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trialing >3 days remaining -> pristine', () => {
|
||||||
|
// 7 days from frozen now.
|
||||||
|
setSubscription({
|
||||||
|
status: 'trialing',
|
||||||
|
current_period_end: '2026-05-13T00:00:00Z',
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('pristine')
|
||||||
|
expect(result.current.daysRemaining).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trialing 1-3 days remaining -> warning', () => {
|
||||||
|
// 2 days from frozen now.
|
||||||
|
setSubscription({
|
||||||
|
status: 'trialing',
|
||||||
|
current_period_end: '2026-05-08T00:00:00Z',
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('warning')
|
||||||
|
expect(result.current.daysRemaining).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trialing exactly 24 hours remaining -> warning (boundary, not urgent)', () => {
|
||||||
|
// Exactly 1.0 fractional day from frozen now — must sit on the warning
|
||||||
|
// side per spec (1–3 days inclusive of 1).
|
||||||
|
setSubscription({
|
||||||
|
status: 'trialing',
|
||||||
|
current_period_end: '2026-05-07T00:00:00Z',
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('warning')
|
||||||
|
expect(result.current.daysRemaining).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trialing <1 day remaining -> urgent', () => {
|
||||||
|
// 12 hours from frozen now -> Math.ceil(0.5) = 1 day.
|
||||||
|
setSubscription({
|
||||||
|
status: 'trialing',
|
||||||
|
current_period_end: '2026-05-06T12:00:00Z',
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('urgent')
|
||||||
|
expect(result.current.daysRemaining).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trialing past period_end -> expired', () => {
|
||||||
|
setSubscription({
|
||||||
|
status: 'trialing',
|
||||||
|
current_period_end: '2026-05-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('expired')
|
||||||
|
expect(result.current.daysRemaining).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
86
frontend/src/hooks/useTrialBanner.ts
Normal file
86
frontend/src/hooks/useTrialBanner.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
|
||||||
|
export type TrialBannerStage =
|
||||||
|
| 'pristine'
|
||||||
|
| 'warning'
|
||||||
|
| 'urgent'
|
||||||
|
| 'expired'
|
||||||
|
| 'complimentary'
|
||||||
|
| 'paid'
|
||||||
|
| 'past_due'
|
||||||
|
| 'canceled'
|
||||||
|
|
||||||
|
export interface TrialBannerResult {
|
||||||
|
stage: TrialBannerStage | null
|
||||||
|
daysRemaining: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the trial-banner display stage from the current subscription.
|
||||||
|
*
|
||||||
|
* Returns `{ stage: null, daysRemaining: null }` when subscription data is
|
||||||
|
* not yet loaded — this prevents the banner flickering on initial render.
|
||||||
|
*
|
||||||
|
* Subscribes to `useBillingStore` so updates from `refetch()` after a Stripe
|
||||||
|
* checkout propagate automatically.
|
||||||
|
*/
|
||||||
|
export function useTrialBanner(): TrialBannerResult {
|
||||||
|
const subscription = useBillingStore((state) => state.subscription)
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return { stage: null, daysRemaining: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (subscription.status) {
|
||||||
|
case 'complimentary':
|
||||||
|
return { stage: 'complimentary', daysRemaining: null }
|
||||||
|
case 'active':
|
||||||
|
return { stage: 'paid', daysRemaining: null }
|
||||||
|
case 'past_due':
|
||||||
|
return { stage: 'past_due', daysRemaining: null }
|
||||||
|
case 'canceled':
|
||||||
|
return { stage: 'canceled', daysRemaining: null }
|
||||||
|
case 'trialing': {
|
||||||
|
const end = subscription.current_period_end
|
||||||
|
? new Date(subscription.current_period_end).getTime()
|
||||||
|
: null
|
||||||
|
if (end === null || Number.isNaN(end)) {
|
||||||
|
// Trialing without a period end is malformed; treat as expired so the
|
||||||
|
// upgrade prompt still surfaces rather than silently swallowing it.
|
||||||
|
return { stage: 'expired', daysRemaining: null }
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
if (end <= now) {
|
||||||
|
return { stage: 'expired', daysRemaining: 0 }
|
||||||
|
}
|
||||||
|
const msRemaining = end - now
|
||||||
|
// Use fractional days for stage thresholds so exactly 24h remaining
|
||||||
|
// sits on the warning side (1.0), not urgent. The displayed integer
|
||||||
|
// countdown still uses Math.ceil so "0.5 days" renders as "1 day".
|
||||||
|
const fractionalDays = msRemaining / MS_PER_DAY
|
||||||
|
const daysRemaining = Math.ceil(fractionalDays)
|
||||||
|
// Spec thresholds:
|
||||||
|
// >3 days remaining → pristine
|
||||||
|
// 1–3 days → warning (inclusive of exactly 1)
|
||||||
|
// <1 day → urgent
|
||||||
|
let stage: TrialBannerStage = 'pristine'
|
||||||
|
if (fractionalDays < 1) stage = 'urgent'
|
||||||
|
else if (fractionalDays <= 3) stage = 'warning'
|
||||||
|
return { stage, daysRemaining }
|
||||||
|
}
|
||||||
|
case 'incomplete':
|
||||||
|
// Not in the spec's matrix; surface as null so the banner stays hidden
|
||||||
|
// until checkout actually resolves.
|
||||||
|
return { stage: null, daysRemaining: null }
|
||||||
|
default: {
|
||||||
|
// Defensive fallthrough for unknown statuses — keep the banner hidden.
|
||||||
|
const _exhaustive: never = subscription.status as never
|
||||||
|
void _exhaustive
|
||||||
|
return { stage: null, daysRemaining: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTrialBanner
|
||||||
Reference in New Issue
Block a user