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:
2026-05-06 20:52:18 -04:00
parent 7a9cb4b03b
commit 0b5ed9aa10
8 changed files with 538 additions and 0 deletions

View 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
// 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