feat: self-serve signup Phase 2 (frontend cutover) (#162)
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled

Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
This commit was merged in pull request #162.
This commit is contained in:
2026-05-07 18:42:20 +00:00
committed by chihlasm
parent f918b766b0
commit f1be3abcc5
123 changed files with 11563 additions and 559 deletions

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { configApi, type PublicConfig } from '@/api/config'
/**
* Module-scope cache: the public config endpoint is fetched at most once
* per page load. Subsequent hook mounts return the cached value synchronously
* (after the initial state update).
*/
let cached: PublicConfig | null = null
let inFlight: Promise<PublicConfig> | null = null
const subscribers = new Set<(c: PublicConfig) => void>()
function envFallback(): PublicConfig {
// Falls back to build-time flag when the public config endpoint is
// unreachable. Defaults to the legacy invite-only behavior so that
// a backend hiccup never opens public signup.
const selfServe =
String(import.meta.env.VITE_SELF_SERVE_ENABLED ?? '').toLowerCase() === 'true'
return {
self_serve_enabled: selfServe,
oauth_providers: [],
}
}
async function loadConfig(): Promise<PublicConfig> {
if (cached) return cached
if (inFlight) return inFlight
inFlight = configApi
.getPublic()
.then((c) => {
cached = c
subscribers.forEach((cb) => cb(c))
return c
})
.catch(() => {
const fallback = envFallback()
cached = fallback
subscribers.forEach((cb) => cb(fallback))
return fallback
})
.finally(() => {
inFlight = null
})
return inFlight
}
/** Test-only: clear the module-scope cache between tests. */
export function __resetAppConfigCache() {
cached = null
inFlight = null
subscribers.clear()
}
/** Test-only: prime the module-scope cache so hook returns synchronously. */
export function __setAppConfigCache(c: PublicConfig) {
cached = c
}
export interface UseAppConfigResult {
self_serve_enabled: boolean
oauth_providers: string[]
isLoading: boolean
}
export function useAppConfig(): UseAppConfigResult {
const [config, setConfig] = useState<PublicConfig | null>(cached)
useEffect(() => {
if (cached) return
let active = true
const handler = (c: PublicConfig) => {
if (active) setConfig(c)
}
subscribers.add(handler)
void loadConfig()
return () => {
active = false
subscribers.delete(handler)
}
}, [])
if (config) {
return {
self_serve_enabled: config.self_serve_enabled,
oauth_providers: config.oauth_providers,
isLoading: false,
}
}
return {
self_serve_enabled: false,
oauth_providers: [],
isLoading: true,
}
}
export default useAppConfig

View File

@@ -0,0 +1,32 @@
import { useEffect } from 'react'
import { useAuthStore } from '@/store/authStore'
import { useBillingStore } from '@/store/billingStore'
const POLL_INTERVAL_MS = 60_000
/**
* Re-fetches billing state every 60s while a user is logged in.
*
* Mount once at the top of the authenticated dashboard tree. Polling
* automatically pauses when the auth store reports no logged-in user.
*
* Note: this is a v1 simple-interval implementation; a later task may
* swap to SSE / visibility-aware polling.
*/
export function useBillingPoll(): void {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
useEffect(() => {
if (!isAuthenticated) return
const id = window.setInterval(() => {
void useBillingStore.getState().refetch()
}, POLL_INTERVAL_MS)
return () => {
window.clearInterval(id)
}
}, [isAuthenticated])
}
export default useBillingPoll

View File

@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useFeature } from './useFeature'
import { useBillingStore } from '@/store/billingStore'
describe('useFeature', () => {
beforeEach(() => {
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('returns false when flag absent', () => {
const { result } = renderHook(() => useFeature('does_not_exist'))
expect(result.current).toBe(false)
})
it('returns true when flag is enabled', () => {
useBillingStore.setState({ enabledFeatures: { ai_builder: true } })
const { result } = renderHook(() => useFeature('ai_builder'))
expect(result.current).toBe(true)
})
it('returns false when flag is explicitly disabled', () => {
useBillingStore.setState({ enabledFeatures: { ai_builder: false } })
const { result } = renderHook(() => useFeature('ai_builder'))
expect(result.current).toBe(false)
})
it('updates when store changes (subscribes to store)', () => {
const { result } = renderHook(() => useFeature('foo'))
expect(result.current).toBe(false)
act(() => {
useBillingStore.setState({ enabledFeatures: { foo: true } })
})
expect(result.current).toBe(true)
})
})

View File

@@ -0,0 +1,16 @@
import { useBillingStore } from '@/store/billingStore'
/**
* Returns whether a feature flag is enabled for the current account.
*
* Reads from `useBillingStore.enabledFeatures`, which is populated by
* `GET /billing/state`. Returns `false` when the flag is absent (closed-by-default).
*
* The hook subscribes to the store so updates from `refetch()` propagate
* without manual refetch in the component.
*/
export function useFeature(flagKey: string): boolean {
return useBillingStore((state) => Boolean(state.enabledFeatures[flagKey]))
}
export default useFeature

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useFeatureLimit, clearUsageCache } from './useFeatureLimit'
import { useBillingStore } from '@/store/billingStore'
vi.mock('@/api/usage', () => ({
usageApi: {
getCount: vi.fn(),
},
}))
import { usageApi } from '@/api/usage'
const mockedGetCount = vi.mocked(usageApi.getCount)
describe('useFeatureLimit', () => {
beforeEach(() => {
clearUsageCache()
mockedGetCount.mockReset()
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('transitions isLoading -> loaded', async () => {
useBillingStore.setState({ planLimits: { active_users: 10 } })
mockedGetCount.mockResolvedValueOnce({ used: 4 })
const { result } = renderHook(() => useFeatureLimit('active_users'))
// Non-blocking initial state.
expect(result.current.isLoading).toBe(true)
expect(result.current.used).toBe(0)
expect(result.current.limit).toBe(10)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.used).toBe(4)
expect(result.current.limit).toBe(10)
expect(result.current.percentage).toBe(40)
expect(result.current.isAtLimit).toBe(false)
})
it('flags isAtLimit when used >= limit', async () => {
useBillingStore.setState({ planLimits: { seats: 3 } })
mockedGetCount.mockResolvedValueOnce({ used: 3 })
const { result } = renderHook(() => useFeatureLimit('seats'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.isAtLimit).toBe(true)
expect(result.current.percentage).toBe(100)
})
it('returns null percentage when limit is null (unlimited)', async () => {
useBillingStore.setState({ planLimits: { sessions: null } })
mockedGetCount.mockResolvedValueOnce({ used: 7 })
const { result } = renderHook(() => useFeatureLimit('sessions'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.limit).toBe(null)
expect(result.current.percentage).toBe(null)
expect(result.current.isAtLimit).toBe(false)
})
it('resets isLoading=true synchronously when `field` prop changes', async () => {
useBillingStore.setState({ planLimits: { max_trees: 5, max_users: 10 } })
mockedGetCount.mockResolvedValueOnce({ used: 2 }) // for max_trees
mockedGetCount.mockResolvedValueOnce({ used: 3 }) // for max_users (slow)
const { result, rerender } = renderHook(
({ field }: { field: string }) => useFeatureLimit(field),
{ initialProps: { field: 'max_trees' } },
)
// First field resolves.
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.used).toBe(2)
expect(result.current.limit).toBe(5)
// Switch field. Next render must report isLoading=true (no stale data
// bleed-through) before the new fetch resolves.
rerender({ field: 'max_users' })
expect(result.current.isLoading).toBe(true)
expect(result.current.used).toBe(0)
expect(result.current.limit).toBe(10)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.used).toBe(3)
expect(result.current.limit).toBe(10)
})
it('degrades to used=0 on fetch error (404 from missing endpoint)', async () => {
useBillingStore.setState({ planLimits: { active_users: 5 } })
mockedGetCount.mockRejectedValueOnce(new Error('Request failed with status 404'))
const { result } = renderHook(() => useFeatureLimit('active_users'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.used).toBe(0)
expect(result.current.limit).toBe(5)
expect(result.current.percentage).toBe(0)
})
})

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react'
import { useBillingStore } from '@/store/billingStore'
import { usageApi } from '@/api/usage'
const CACHE_TTL_MS = 60 * 1000
interface CacheEntry {
used: number
timestamp: number
}
const cache = new Map<string, CacheEntry>()
/** Clear the usage cache (call on logout to prevent stale data across users). */
export function clearUsageCache() {
cache.clear()
}
export interface FeatureLimitResult {
used: number
limit: number | null
/** null when limit is null (unlimited) or unknown */
percentage: number | null
isAtLimit: boolean
isLoading: boolean
}
function coerceLimit(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
if (raw === null || raw === undefined) return null
// The store types planLimits as Record<string, unknown>; the backend
// currently returns numbers, but defensively handle string ints too.
if (typeof raw === 'string') {
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
return null
}
/**
* Returns progress against a quantitative plan limit.
*
* `limit` comes from `useBillingStore.planLimits[field]`, which is read
* synchronously from the store. `used` is fetched lazily from
* `GET /api/v1/usage/{field}` on mount and cached for 60s in a module-level
* map keyed by field.
*
* Render is non-blocking: the hook returns `isLoading=true` (with `used=0`)
* until the usage fetch resolves. On 404 or any error the hook degrades to
* `used=0` with `isLoading=false` rather than surfacing the error — the
* `/usage/{field}` endpoint is not yet implemented on the backend (planned).
*/
export function useFeatureLimit(field: string): FeatureLimitResult {
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
const [state, setState] = useState(() => {
const existing = cache.get(field)
const fresh = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
return {
field,
used: fresh ? existing.used : 0,
isLoading: !fresh,
}
})
useEffect(() => {
const existing = cache.get(field)
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync local hook state with fresh module cache on field change
setState({ field, used: existing.used, isLoading: false })
return
}
let cancelled = false
setState({ field, used: 0, isLoading: true })
usageApi
.getCount(field)
.then((result) => {
if (cancelled) return
cache.set(field, { used: result.used, timestamp: Date.now() })
setState({ field, used: result.used, isLoading: false })
})
.catch(() => {
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
// 404s and other errors degrade to used=0 silently — no toast.
if (cancelled) return
setState({ field, used: 0, isLoading: false })
})
return () => {
cancelled = true
}
}, [field])
const used = state.field === field ? state.used : 0
const isLoading = state.field === field ? state.isLoading : true
const percentage =
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
const isAtLimit = limit !== null && used >= limit
return { used, limit, percentage, isAtLimit, isLoading }
}
export default useFeatureLimit

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import { getOnboardingStatus } from '@/api/onboarding'
import type { OnboardingStatus } from '@/api/onboarding'
/**
* Tiny shared hook that fetches `/users/onboarding-status` once on mount.
*
* Used by `NextStepCard`, `SetupChecklist`, and `QuickStartPage` so the toggle
* row can disappear when there's nothing to show. Each consumer has its own
* state — fetches are not deduplicated. That's fine for now; if it becomes a
* problem we can lift this into a Zustand store or react-query.
*/
export function useOnboardingStatus(): OnboardingStatus | null {
const [status, setStatus] = useState<OnboardingStatus | null>(null)
useEffect(() => {
getOnboardingStatus()
.then(setStatus)
.catch(() => {
// Silently fail — never block the dashboard if the endpoint is down.
})
}, [])
return status
}
export default useOnboardingStatus

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useTrialBanner } from './useTrialBanner'
import { useBillingStore } from '@/store/billingStore'
import type { SubscriptionState } from '@/types/billing'
const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
function makeSub(overrides: Partial<SubscriptionState>): SubscriptionState {
return {
status: 'trialing',
plan: 'starter',
current_period_start: '2026-05-01T00:00:00Z',
current_period_end: null,
cancel_at_period_end: false,
seat_limit: null,
has_pro_entitlement: false,
is_paid: false,
...overrides,
}
}
function setSubscription(overrides: Partial<SubscriptionState>) {
useBillingStore.setState({ subscription: makeSub(overrides) })
}
describe('useTrialBanner', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(FROZEN_NOW)
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
afterEach(() => {
vi.useRealTimers()
})
describe('stage matches subscription state matrix', () => {
it('returns null when subscription is null (no flicker on initial load)', () => {
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe(null)
expect(result.current.daysRemaining).toBe(null)
})
it('complimentary status -> complimentary stage', () => {
setSubscription({ status: 'complimentary' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('complimentary')
})
it('active status -> paid stage', () => {
setSubscription({ status: 'active' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('paid')
})
it('past_due status -> past_due stage', () => {
setSubscription({ status: 'past_due' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('past_due')
})
it('canceled status -> canceled stage', () => {
setSubscription({ status: 'canceled' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('canceled')
})
it('trialing >3 days remaining -> pristine', () => {
// 7 days from frozen now.
setSubscription({
status: 'trialing',
current_period_end: '2026-05-13T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('pristine')
expect(result.current.daysRemaining).toBe(7)
})
it('trialing 1-3 days remaining -> warning', () => {
// 2 days from frozen now.
setSubscription({
status: 'trialing',
current_period_end: '2026-05-08T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('warning')
expect(result.current.daysRemaining).toBe(2)
})
it('trialing exactly 24 hours remaining -> warning (boundary, not urgent)', () => {
// Exactly 1.0 fractional day from frozen now — must sit on the warning
// side per spec (13 days inclusive of 1).
setSubscription({
status: 'trialing',
current_period_end: '2026-05-07T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('warning')
expect(result.current.daysRemaining).toBe(1)
})
it('trialing <1 day remaining -> urgent', () => {
// 12 hours from frozen now -> Math.ceil(0.5) = 1 day.
setSubscription({
status: 'trialing',
current_period_end: '2026-05-06T12:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('urgent')
expect(result.current.daysRemaining).toBe(1)
})
it('trialing past period_end -> expired', () => {
setSubscription({
status: 'trialing',
current_period_end: '2026-05-01T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('expired')
expect(result.current.daysRemaining).toBe(0)
})
})
})

View File

@@ -0,0 +1,87 @@
import { useState } from 'react'
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)
const [now] = useState(() => Date.now())
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 }
}
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