Files
resolutionflow/frontend/src/hooks/useTrialBanner.ts
Michael Chihlas 0b5ed9aa10 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>
2026-05-06 20:55:58 -04:00

87 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// 13 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