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>
87 lines
2.9 KiB
TypeScript
87 lines
2.9 KiB
TypeScript
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
|