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