All files / web/src/hooks useTier.ts

58.27% Statements 88/151
100% Branches 0/0
0% Functions 0/6
58.27% Lines 88/151

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 1521x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x           1x 1x 1x 1x 1x 1x 1x 1x                               1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x           1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                                   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x           1x 1x 1x 1x 1x 1x 1x 1x 1x                                  
'use client'
 
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { billingKeys } from '@/lib/queryKeys'
import type { TierName, DurationOption } from '@/lib/tier-limits'
 
export interface TierLimitsResponse {
  maxPracticeStudents: number | null // null = unlimited
  maxSessionMinutes: DurationOption
  maxSessionsPerWeek: number | null // null = unlimited
  maxOfflineParsingPerMonth: number
}
 
export interface TierResponse {
  tier: TierName
  limits: TierLimitsResponse
}
 
export interface EffectiveTierResponse extends TierResponse {
  /** Non-null when a different parent provides the best tier for this student. */
  providedBy: { name: string } | null
}
 
const DEFAULT_TIER: TierResponse = {
  tier: 'guest',
  limits: {
    maxPracticeStudents: 1,
    maxSessionMinutes: 10,
    maxSessionsPerWeek: null,
    maxOfflineParsingPerMonth: 3,
  },
}
 
async function fetchTier(): Promise<TierResponse> {
  const res = await fetch('/api/billing/tier')
  if (!res.ok) throw new Error('Failed to fetch tier')
  return res.json()
}
 
/**
 * Hook to get the current user's subscription tier and limits.
 *
 * Prefetched server-side in root layout — no extra request on initial load.
 * Revalidates in background after 60s stale time.
 */
export function useTier() {
  const { data, isLoading } = useQuery({
    queryKey: billingKeys.tier(),
    queryFn: fetchTier,
    staleTime: 60_000,
  })

  return useMemo(
    () => ({
      tier: data?.tier ?? DEFAULT_TIER.tier,
      limits: data?.limits ?? DEFAULT_TIER.limits,
      isLoading,
    }),
    [data?.tier, data?.limits, isLoading]
  )
}
 
// ---------------------------------------------------------------------------
// Family coverage hook
// ---------------------------------------------------------------------------
 
export interface FamilyCoverageResponse {
  isCovered: boolean
  coveredBy: { userId: string; name: string } | null
  coveredChildCount: number
  totalChildCount: number
}
 
async function fetchFamilyCoverage(): Promise<FamilyCoverageResponse> {
  const res = await fetch('/api/billing/coverage')
  if (!res.ok) throw new Error('Failed to fetch family coverage')
  return res.json()
}
 
const DEFAULT_COVERAGE: FamilyCoverageResponse = {
  isCovered: false,
  coveredBy: null,
  coveredChildCount: 0,
  totalChildCount: 0,
}
 
/**
 * Hook to check whether any of the current user's children are covered
 * by another parent's family subscription.
 *
 * Used on pricing/settings pages to surface inherited coverage.
 */
export function useFamilyCoverage() {
  const { data, isLoading } = useQuery({
    queryKey: billingKeys.coverage(),
    queryFn: fetchFamilyCoverage,
    staleTime: 60_000,
  })

  return useMemo(
    () => ({
      isCovered: data?.isCovered ?? DEFAULT_COVERAGE.isCovered,
      coveredBy: data?.coveredBy ?? DEFAULT_COVERAGE.coveredBy,
      coveredChildCount: data?.coveredChildCount ?? DEFAULT_COVERAGE.coveredChildCount,
      totalChildCount: data?.totalChildCount ?? DEFAULT_COVERAGE.totalChildCount,
      isLoading,
    }),
    [data?.isCovered, data?.coveredBy, data?.coveredChildCount, data?.totalChildCount, isLoading]
  )
}
 
// ---------------------------------------------------------------------------
// Effective tier hook (per-student)
// ---------------------------------------------------------------------------
 
const DEFAULT_EFFECTIVE: EffectiveTierResponse = {
  ...DEFAULT_TIER,
  providedBy: null,
}
 
async function fetchEffectiveTier(playerId: string): Promise<EffectiveTierResponse> {
  const res = await fetch(`/api/players/${playerId}/effective-tier`)
  if (!res.ok) throw new Error('Failed to fetch effective tier')
  return res.json()
}
 
/**
 * Hook to get the effective subscription tier for a *student*,
 * considering all linked parents' plans (not just the logged-in user).
 *
 * Use this in student-scoped UI (StartPracticeModal, DurationSelector)
 * instead of `useTier()` which only returns the acting user's own tier.
 */
export function useEffectiveTier(playerId: string) {
  const { data, isLoading } = useQuery({
    queryKey: billingKeys.effectiveTier(playerId),
    queryFn: () => fetchEffectiveTier(playerId),
    staleTime: 60_000,
  })

  return useMemo(
    () => ({
      tier: data?.tier ?? DEFAULT_EFFECTIVE.tier,
      limits: data?.limits ?? DEFAULT_EFFECTIVE.limits,
      providedBy: data?.providedBy ?? null,
      isLoading,
    }),
    [data?.tier, data?.limits, data?.providedBy, isLoading]
  )
}